Quellcode durchsuchen

feat:食堂及跟进

lizhi vor 3 Monaten
Ursprung
Commit
f7d35b92be
32 geänderte Dateien mit 1948 neuen und 278 gelöschten Zeilen
  1. 175 0
      protected/controllers/CanteenController.php
  2. 3 0
      protected/controllers/CommonController.php
  3. 42 18
      protected/controllers/FollowController.php
  4. 1 1
      protected/controllers/SiteController.php
  5. 2 2
      protected/include/DBColumn.php
  6. 7 6
      protected/include/DBTable.php
  7. 1 1
      protected/include/Helper.php
  8. 16 1
      protected/include/LewaimaiAdminPingtaiAuth.php
  9. 13 17
      script/upgrade/1.0.0.sql
  10. 64 0
      web/src/api/canteenApi.ts
  11. 72 0
      web/src/api/followApi.ts
  12. 1 1
      web/src/components/core/base/art-logo/index.vue
  13. 125 0
      web/src/components/custom/ArtUploadImgs.vue
  14. 14 88
      web/src/components/custom/FollowDialog.vue
  15. 12 3
      web/src/components/custom/FollowDrawer.vue
  16. 8 55
      web/src/locales/langs/en.json
  17. 10 57
      web/src/locales/langs/zh.json
  18. 99 0
      web/src/router/routes/asyncRoutes.ts
  19. 5 0
      web/src/router/routesAlias.ts
  20. 47 0
      web/src/typings/api.d.ts
  21. 13 0
      web/src/typings/form.d.ts
  22. 202 0
      web/src/views/canteen/edit.vue
  23. 210 0
      web/src/views/canteen/follow/index.vue
  24. 87 0
      web/src/views/canteen/follow/info.vue
  25. 80 0
      web/src/views/canteen/follow/modules/user-search.vue
  26. 99 0
      web/src/views/canteen/info.vue
  27. 420 0
      web/src/views/canteen/list/index.vue
  28. 92 0
      web/src/views/canteen/list/user-search.vue
  29. 14 14
      web/src/views/school/follow/index.vue
  30. 4 10
      web/src/views/school/list/index.vue
  31. 4 3
      web/src/views/school/relation/index.vue
  32. 6 1
      web/src/views/system/user-center/index.vue

+ 175 - 0
protected/controllers/CanteenController.php

@@ -0,0 +1,175 @@
+<?php
+
+class CanteenController extends Controller
+{
+    public static string $table = 'canteen';
+    public function actionInfo()
+    {
+        $id = Helper::getPostInt('id');
+        if ($id <= 0) {
+            Helper::error('参数错误');
+        }
+        $data = DB::getInfoById(self::$table, $id);
+        if (!$data) {
+            Helper::error('数据不存在');
+        }
+        $data['stall_imgs'] = Helper::formatImgsFiled($data['stall_imgs']);
+        $school = DB::getInfoById('school', $data['school_id'], 'name');
+        $company = DB::getInfoById('company', $data['company_id'], 'name');
+        $data['company_name'] = $company['name']?? '';
+        $data['school_name'] = $school['name']?? '';
+        Helper::ok($data);
+    }
+
+    public function actionList()
+    {
+        $filter = [
+            'is_del' => 0,
+            'school_id' => Helper::getPostString('school_id') ? : null,
+        ];
+        if ($name = Helper::getPostString('name')) {
+            $filter['name'] = '%' . $name;
+        }
+        if ($company_id = Helper::getPostInt('company_id')) {
+            $rs = Helper::arrayColumn(
+                DB::getListWithCriteria(
+                    'company_canteen_relation',
+                    DbCriteria::simpleCompare(['company_id' => $company_id])
+                ),
+                'canteen_id'
+            );
+            $filter['id'] = $rs ?: [-1];
+        }
+        $cri = DbCriteria::simpleCompareWithPage($filter);
+        $data = DB::getListWithCriteria(self::$table, $cri);
+        if (!empty($data['records'])) {
+            $users = Helper::arrayColumn(
+                DB::getListWithCriteria('useradmin', DbCriteria::simpleCompare([])->setSelect('id, username')),
+                'username',
+                'id'
+            );
+            $schools = Helper::arrayColumn(
+                DB::getListWithCriteria('school', DbCriteria::simpleCompare([])->setSelect('id, name')),
+                'name',
+                'id'
+            );
+            $data['records'] = array_map(function ($item) use ($users, $schools) {
+                $item['last_user_name'] = $users[$item['last_user_id']] ?? '-';
+                $item['school_name'] = $schools[$item['school_id']] ?? '-';
+                $item['stall_imgs'] = Helper::formatImgsFiled($item['stall_imgs']);
+                return $item;
+            }, $data['records']);
+        }
+        Helper::ok($data);
+    }
+
+    /**
+     * 下拉列表获取
+     * @return void
+     */
+    public function actionGetSelectList()
+    {
+        $cri = DbCriteria::simpleCompare(['t.is_del' => 0])->setAlias('t')
+            ->setSelect('t.id, t.name, t.school_id, s.name as school_name')
+            ->setJoin('LEFT JOIN wx_school s on s.id=t.school_id');
+        $data = DB::getListWithCriteria(self::$table, $cri);
+        if (empty($data['records'])) {
+            return [];
+        }
+        $newData = [];
+        foreach ($data['records'] as $item) {
+            $sid = $item['school_id'];
+            if (!isset($newData[$sid])) {
+                $newData[$sid] = [
+                    'id' => $sid,
+                    'name' => $item['school_name'],
+                    'children' => [],
+                ];
+            }
+            $newData[$sid]['children'][] = ['id' => $item['id'], 'name' => $item['name']];
+        }
+        Helper::ok(array_values($newData));
+    }
+
+    public function actionDelete()
+    {
+        $id = Helper::getPostInt('id');
+        if ($id < 1) {
+            Helper::error('参数错误');
+        }
+        Db::updateById(self::$table, ['is_del' => 1], $id);
+        Helper::ok();
+    }
+
+    public function actionAdd()
+    {
+        $this->_save();
+    }
+
+    public function actionEdit()
+    {
+        $id = Helper::getPostInt('id');
+        if (!$id) {
+            Helper::error('参数错误');
+        }
+        $this->_save($id);
+    }
+
+    private function _save($id = 0)
+    {
+        $data = [
+            'school_id' => Helper::getPostInt('school_id'),
+            'name' => Helper::getPostString('name'),
+            'stall_num' => Helper::getPostInt('stall_num'),
+            'is_direct' => Helper::getPostInt('is_direct'),
+            'stall_imgs' => Helper::getArrParam($_POST, 'stall_imgs', Helper::PARAM_KEY_TYPE['array_string']),
+            'username' => Helper::getPostString('username'),
+            'phone' => Helper::getPostString('phone'),
+            'weixin' => Helper::getPostString('weixin'),
+            'memo' => Helper::getPostString('memo'),
+        ];
+
+        $notNullField = ["school_id","name","stall_num","is_direct","username","phone","weixin"];
+        $allowEmptyField = ["school_id","stall_num","is_direct"];
+
+        // 空字段检测
+        if (!Helper::checkEmptyKey($data, $notNullField, $allowEmptyField)) {
+            Helper::error('参数错误');
+        }
+
+        $name = $data['name'];
+        // 检测名称重复
+        $cri = DbCriteria::simpleCompare(['name' => $name])->setSelect('id');
+        if ($id > 0) {
+            $cri->addCondition('id!=' . $id);
+        }
+        if ($fid = DB::getScalerWithCriteria(self::$table, $cri)) {
+            Helper::error('食堂名称已存在 ' . $fid);
+        }
+        $data['stall_imgs'] = $data['stall_imgs'] ? implode(',', $data['stall_imgs']) : '';
+
+        if ($id) {
+            DB::updateById(self::$table, $data, $id);
+        } else {
+            DB::addData(self::$table, $data);
+        }
+        Helper::ok();
+    }
+
+    public function actionUpdateAttr()
+    {
+        $id = Helper::getPostInt('id');
+        $attr = Helper::getPostString('attr');
+        $value = Helper::getPostString('value');
+        if ($id <= 0 || !$attr) {
+            Helper::error('参数错误');
+        }
+        if (!in_array($attr, ['is_direct', 'stall_num'])) {
+            Helper::error('参数错误2');
+        }
+        if (DB::updateById(self::$table, [$attr => $value], $id) === false) {
+            Helper::error('更新失败');
+        }
+        Helper::ok();
+    }
+}

+ 3 - 0
protected/controllers/CommonController.php

@@ -24,6 +24,9 @@ class CommonController extends Controller
             $upType = 'avatar';
             $upArr = $_FILES['avatar'];
             $maxSize = 0.3;
+        } elseif (!empty($_FILES['canteen'])) {
+            $upType = 'canteen';
+            $upArr = $_FILES['canteen'];
         } else {
             Helper::error('上传有误');
         }

+ 42 - 18
protected/controllers/FollowController.php

@@ -15,7 +15,7 @@ class FollowController extends Controller
             'first_id' => 'school_id',
             'second_id' => 'canteen_id',
             'table1' => 'school',
-            'table2' => 'canteen_id',
+            'table2' => 'canteen',
         ],
         'company' => [
             'table' => 'company_follow',
@@ -46,22 +46,41 @@ class FollowController extends Controller
 
     private function _add($type)
     {
+        $userID = Yii::app()->user->_id;
         $this->type = $type;
         $this->tableArr = self::TYPE_TABLE_MAP[$this->type];
         $firstId = Helper::getPostInt('first_id');
         $secondId = Helper::getPostInt('second_id');
         $chatImgs = Helper::getArrParam($_POST, 'chat_imgs', Helper::PARAM_KEY_TYPE['array_string']);
-        $detail = Helper::getPostString('detail');
+        $detail = $_POST['detail']?? '';
         if (empty($firstId) || empty($secondId) || empty($detail) || empty($chatImgs)) {
             Helper::error('参数错误');
         }
-        DB::addData($this->tableArr['table'], [
-            $this->tableArr['first_id'] => $firstId,
-            $this->tableArr['second_id'] => $secondId,
-            'detail' => $detail,
-            'chat_imgs' => implode(',', $chatImgs),
-            'user_id' => Yii::app()->user->_id,
-        ]);
+        $trans = \Yii::app()->db->beginTransaction();
+        try {
+            DB::addData($this->tableArr['table'], [
+                $this->tableArr['first_id'] => $firstId,
+                $this->tableArr['second_id'] => $secondId,
+                'detail' => $detail,
+                'chat_imgs' => implode(',', $chatImgs),
+                'user_id' => $userID,
+            ]);
+            // 最后一次跟进时间更新
+            $upInfo = ['last_user_id' => $userID, 'last_date' => date('Y-m-d H:i:s')];
+            if ($this->type == 'school') {
+                DB::updateById('school', $upInfo, $firstId);
+                DB::updateById('school_contact', $upInfo, $secondId);
+            } elseif ($this->type == 'canteen') {
+                DB::updateById('canteen', $upInfo, $firstId);
+            } elseif ($this->type == 'company') {
+                DB::updateById('company', $upInfo, $firstId);
+                DB::updateById('company_contact', $upInfo, $secondId);
+            }
+            $trans->commit();
+        } catch (Exception $e) {
+            $trans->rollback();
+            Helper::error($e->getMessage());
+        }
         Helper::ok();
     }
 
@@ -146,10 +165,18 @@ class FollowController extends Controller
         $this->type = $type;
         $this->tableArr = self::TYPE_TABLE_MAP[$this->type];
         $schoolArr = Helper::getArrParam($_POST, 'school', Helper::PARAM_KEY_TYPE['array_int']);
-        $filter = [
-            $this->tableArr['first_id'] => Helper::getPostInt('first_id')? : null,
-            $this->tableArr['second_id'] => Helper::getPostInt('first_id')? : null,
-        ];
+        if ($schoolArr) {
+            $filter = [
+                $this->tableArr['first_id'] => $schoolArr[0]? : null,
+                $this->tableArr['second_id'] => $schoolArr[1]? : null,
+            ];
+        } else {
+            $filter = [
+                $this->tableArr['first_id'] => Helper::getPostInt('first_id')? : null,
+                $this->tableArr['second_id'] => Helper::getPostInt('second_id')? : null,
+            ];
+        }
+
         if ($phone = Helper::getPostString('phone')) {
             $rs = Helper::arrayColumn(DB::getListWithCriteria($this->tableArr['table2'], DbCriteria::simpleCompare(['phone' => $phone])->setSelect('id')), 'id');
             $filter[$this->tableArr['second_id']] = $rs?: [-1];
@@ -187,10 +214,7 @@ class FollowController extends Controller
         if ($secondIds) {
             $cri = DbCriteria::simpleCompare(['id' => $secondIds])->setSelect('id,name,position,weixin,phone');
             if ($this->type == 'canteen') {
-                $cri = DbCriteria::simpleCompare(['t.id' => $secondIds])
-                    ->setAlias('t')
-                    ->setSelect('t.id,c.name,c.position,c.weixin,c.phone')
-                    ->setJoin('left join wx_canteen_contact c on c.canteen_id = t.id');
+                $cri->setSelect('id,name,weixin,phone');
             }
             $seconds = Helper::arrayColumn(DB::getListWithCriteria($this->tableArr['table2'], $cri), null, 'id');
         }
@@ -198,7 +222,7 @@ class FollowController extends Controller
             $uid = $item['user_id'];
             $f1 = $item[$field1];
             $f2 = $item[$field2];
-            $item['chat_imgs'] = Helper::formatImgFiled($item['chat_imgs']);
+            $item['chat_imgs'] = Helper::formatImgsFiled($item['chat_imgs']);
             $item['create_date'] = date('Y-m-d H:i', strtotime($item['create_date']));
             $item['user_name'] = $users[$uid]['username'] ?? '';
             $item['avatar'] = $users[$uid]['avatar'] ? Helper::getImageUrl($users[$uid]['avatar']) : '';

+ 1 - 1
protected/controllers/SiteController.php

@@ -81,7 +81,7 @@ class SiteController extends Controller
     /*******************************   测试相关代码   ***************************************/
     public function actionPhp()
     {
-        (new DBTable(Helper::getGetString('t1')))->echoEditHtml();
+        (new DBTable(Helper::getGetString('t1')))->echoEditPhp();
     }
 
     public function actionTs()

+ 2 - 2
protected/include/DBColumn.php

@@ -188,7 +188,7 @@ Detail;
 <el-col {$elHtml}>
 <ElFormItem label="{$this->comment}" prop="{$this->field}">
      <ElInput v-model="formData.{$this->field}" $maxLengthHtml type="{$type}"/>
-</ElFormItem>                                                                                                                                               ≤,≤≥≤ rmItem>
+</ElFormItem>
 </el-col>
 input;
     }
@@ -215,7 +215,7 @@ input;
 
 <el-col :xs="24" :lg="8" :sm="12">
 <ElFormItem label="{$this->comment}" prop="{$this->field}">
-  <el-select v-model="formData.{$this->field}" placeholder="请选择">
+  <el-select v-model="formData.{$this->field}" placeholder="请选择" :empty-values="[0]" :value-on-clear="0">
     <el-option v-for="item in [{$data}]" :key="item.value" :value="item.value" :label="item.label"/>
   </el-select>
 </ElFormItem>

+ 7 - 6
protected/include/DBTable.php

@@ -6,7 +6,7 @@ class DBTable {
     public array $columnArray;
     public string $line = '<br/>';
 
-    const array FORM_EXCLUDE_COLUMNS = ['create_date', 'update_date', 'is_del', 'id', 'city', 'area', 'province'];
+    const array FORM_EXCLUDE_COLUMNS = ['create_date', 'update_date', 'is_del', 'id', 'city', 'area', 'province', 'last_user_id', 'last_date'];
     const array INFO_EXCLUDE_COLUMNS = ['create_date', 'update_date', 'is_del'];
     const array TABLE_EXCLUDE_COLUMNS = ['create_date', 'update_date', 'is_del'];
     const array DETAIL_EXCLUDE_COLUMNS = ['is_del'];
@@ -20,7 +20,7 @@ class DBTable {
         }
     }
 
-    public function echoEditHtml(): void
+    public function echoEditPhp(): void
     {
         $notNullField = [];
         $allowEmptyField = [];
@@ -57,7 +57,7 @@ class DBTable {
 
     public function editVue()
     {
-        $formDefault = $this->getFormDefault();
+        $formDefault = $this->getFormDefault(false);
         list($template, $rule) = $this->getFormAndRule();
         $editTmpl = str_replace(
             ['{{template}}', '{{table}}', '{{formDefault}}', '{{formRule}}', '{{ucTable}}'],
@@ -91,7 +91,7 @@ class DBTable {
             $content.= (new DBColumn($column))->getTsDefine() . $this->line;
         }
         $template = <<<typescript
-interface {$this->name} {$this->line}
+interface {$this->name} { {$this->line}
 $content} {$this->line} {$this->line}
 typescript;
 
@@ -102,14 +102,15 @@ typescript;
      * 生成form默认值
      * @return string
      */
-    private function getFormDefault(): string
+    private function getFormDefault($withLine = true): string
     {
         $content = '';
+        $line = $withLine ? $this->line : '';
         foreach ($this->columnArray as $column) {
             if (in_array($column['Field'], self::FORM_EXCLUDE_COLUMNS)) {
                 continue;
             }
-            $content.= (new DBColumn($column))->getDefaultValue() . $this->line;
+            $content.= (new DBColumn($column))->getDefaultValue() . $line;
         }
         return $content;
     }

+ 1 - 1
protected/include/Helper.php

@@ -68,7 +68,7 @@ class Helper
      * @param  string  $separator
      * @return array
      */
-    public static function formatImgFiled(?string $data, string $separator = ','): array
+    public static function formatImgsFiled(?string $data, string $separator = ','): array
     {
         if (!$data) {
             return [];

+ 16 - 1
protected/include/LewaimaiAdminPingtaiAuth.php

@@ -103,9 +103,24 @@ class LewaimaiAdminPingtaiAuth
             // ===================   学校跟进 =======================
             'follow/schoollist' => 120300,
             'follow/schoolall' => 120300,
-            'follow/schoolinfo' => 120200,
+            'follow/schoolinfo' => 120300,
             'follow/schooladd' => 120301,
 
+            // ===================   食堂  =======================
+            'canteen/list' => 130100,
+            'canteen/getselectlist' => 130100,
+            'canteen/info' => 130100,
+            'canteen/add' => 130101,
+            'canteen/edit' => 130102,
+            'canteen/updateattr' => 130102,
+            'canteen/delete' => 130103,
+
+            // ===================   食堂跟进 =======================
+            'follow/canteenlist' => 130300,
+            'follow/canteenall' => 130300,
+            'follow/canteeninfo' => 130300,
+            'follow/canteenadd' => 130301,
+
         ];
 
         return !empty($pageAuth[$page]) && self::getAuth($pageAuth[$page]);

+ 13 - 17
script/upgrade/1.0.0.sql

@@ -49,6 +49,8 @@ CREATE TABLE `wx_school` (
   `qucan_station_distribution` VARCHAR(1000) default '' COMMENT '校门口取餐点离宿舍情况',
   `out_business_description` VARCHAR(1000) default '' COMMENT '校外商圈情况',
   `memo` VARCHAR(255) default '' COMMENT '备注',
+  `last_user_id` int(11) NOT NULL default 0 COMMENT '最后一次跟进人',
+  `last_date` datetime COMMENT '最后一次跟进时间',
   `create_date` datetime NOT NULL DEFAULT now() COMMENT '创建时间',
   `update_date` datetime  NOT NULL DEFAULT now() COMMENT '更新时间',
   PRIMARY KEY (`id`)
@@ -62,7 +64,9 @@ CREATE TABLE `wx_school_contact` (
   `weixin` VARCHAR(20) NOT NULL default '' COMMENT '微信号',
   `position` VARCHAR(20) NOT NULL default '' COMMENT '职位',
   `memo` VARCHAR(255) default '' COMMENT '备注',
+  `last_user_id` int(11) NOT NULL default 0 COMMENT '最后一次跟进人',
   `is_del` TINYINT(4) NOT NULL default 0 COMMENT '是否删除 0-否 1-是',
+  `last_date` datetime COMMENT '最后一次跟进时间',
   `create_date` datetime NOT NULL DEFAULT now() COMMENT '创建时间',
   `update_date` datetime  NOT NULL DEFAULT now() COMMENT '更新时间',
   PRIMARY KEY (`id`),
@@ -90,30 +94,18 @@ CREATE TABLE `wx_canteen` (
   `stall_num` int(11) NOT NULL default 0 COMMENT '档口数量',
   `is_direct` TINYINT(4) NOT NULL default 0 COMMENT '是否直营 0-不是 1-是',
   `stall_imgs` text COMMENT '档口照片',
-  `memo` VARCHAR(255) default '' COMMENT '备注',
-  `is_del` TINYINT(4) NOT NULL default 0 COMMENT '是否删除 0-否 1-是',
-  `create_date` datetime NOT NULL DEFAULT now() COMMENT '创建时间',
-  `update_date` datetime  NOT NULL DEFAULT now() COMMENT '更新时间',
-  PRIMARY KEY (`id`),
-  key `idx_school` (`school_id`) using btree
-) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci comment='学校食堂表';
-
-CREATE TABLE `wx_canteen_contact` (
-  `id` INT(11) NOT NULL AUTO_INCREMENT,
-  `name` VARCHAR(20) NOT NULL COMMENT '名称',
-  `canteen_id` int(11) NOT NULL default 0 COMMENT '食堂ID',
-  `school_id` int(11) NOT NULL default 0 COMMENT '学校ID',
+  `username` VARCHAR(20) NOT NULL COMMENT '食堂经理',
   `phone` VARCHAR(20) NOT NULL COMMENT '手机号',
   `weixin` VARCHAR(20) NOT NULL default '' COMMENT '微信号',
-  `position` VARCHAR(20) NOT NULL default '' COMMENT '职位',
   `memo` VARCHAR(255) default '' COMMENT '备注',
+  `last_user_id` int(11) NOT NULL default 0 COMMENT '最后一次跟进人',
   `is_del` TINYINT(4) NOT NULL default 0 COMMENT '是否删除 0-否 1-是',
   `create_date` datetime NOT NULL DEFAULT now() COMMENT '创建时间',
   `update_date` datetime  NOT NULL DEFAULT now() COMMENT '更新时间',
+  `last_date` datetime COMMENT '最后一次跟进时间',
   PRIMARY KEY (`id`),
-  key `idx_school` (`school_id`) using btree,
-  key `idx_canteen` (`canteen_id`) using btree
-) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci comment='学校食堂联系人表';
+  key `idx_school` (`school_id`) using btree
+) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci comment='学校食堂表';
 
 CREATE TABLE `wx_canteen_follow` (
   `id` INT(11) NOT NULL AUTO_INCREMENT,
@@ -140,6 +132,8 @@ CREATE TABLE `wx_company` (
   `bind_user_id` int(11) NOT NULL default 0 COMMENT '负责人',
   `memo` VARCHAR(255) default '' COMMENT '备注',
   `is_del` TINYINT(4) NOT NULL default 0 COMMENT '是否删除 0-否 1-是',
+  `last_user_id` int(11) NOT NULL default 0 COMMENT '最后一次跟进人',
+  `last_date` datetime COMMENT '最后一次跟进时间',
   `create_date` datetime NOT NULL DEFAULT now() COMMENT '创建时间',
   `update_date` datetime  NOT NULL DEFAULT now() COMMENT '更新时间',
   PRIMARY KEY (`id`)
@@ -155,6 +149,8 @@ CREATE TABLE `wx_company_contact` (
   `canteen_ids` VARCHAR(255) NOT NULL default '' COMMENT '食堂ID字符串(用逗号隔开)',
   `memo` VARCHAR(255) default '' COMMENT '备注',
   `is_del` TINYINT(4) NOT NULL default 0 COMMENT '是否删除 0-否 1-是',
+  `last_user_id` int(11) NOT NULL default 0 COMMENT '最后一次跟进人',
+  `last_date` datetime COMMENT '最后一次跟进时间',
   `create_date` datetime NOT NULL DEFAULT now() COMMENT '创建时间',
   `update_date` datetime  NOT NULL DEFAULT now() COMMENT '更新时间',
   PRIMARY KEY (`id`),

+ 64 - 0
web/src/api/canteenApi.ts

@@ -0,0 +1,64 @@
+import request from '@/utils/http'
+
+export class canteenApi {
+  // 列表
+  static list(params: Api.Common.PaginatingSearchParams) {
+    return request.post<Api.Canteen.ListData>({
+      url: 'canteen/list',
+      params
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 详情
+  static info(id: number) {
+    return request.post<Api.Canteen.Info>({
+      url: 'canteen/info',
+      params: {id:id}
+      // 自定义请求头
+      // headers: {
+      //   'X-Custom-Header': 'your-custom-value'
+      // }
+    })
+  }
+
+  // 新增
+  static add(params: Form.Canteen) {
+    return request.post<any>({
+      url: 'canteen/add',
+      params
+    })
+  }
+
+  // 下拉列表
+  static selectList() {
+    return request.post<Api.Common.SelectRelationInfo[]>({
+      url: 'canteen/getSelectList'
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 编辑属性
+  static updateAttr(params: Form.UpdateAttr) {
+    return request.post<any>({
+      url: 'canteen/updateAttr',
+      params
+    })
+  }
+
+  // 编辑
+  static edit(params: Form.Canteen) {
+    return request.post<any>({
+      url: 'canteen/edit',
+      params
+    })
+  }
+
+  // 删除
+  static delete(params: Api.Common.DeleteParams) {
+    return request.post<any>({
+      url: 'canteen/delete',
+      params
+    })
+  }
+}

+ 72 - 0
web/src/api/followApi.ts

@@ -37,4 +37,76 @@ export class followApi {
       // showErrorMessage: false // 不显示错误消息
     })
   }
+
+  // 食堂跟进详情
+  static canteenInfo(id:number) {
+    return request.post<Api.Follow.FollowInfo>({
+      url: 'follow/canteenInfo',
+      params:{id}
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 食堂添加跟进
+  static canteenFollow(params:Form.Follow) {
+    return request.post<Api.Http.BaseResponse>({
+      url: 'follow/canteenAdd',
+      params
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 食堂跟进列表
+  static canteenList(params: Api.Common.PaginatingSearchParams) {
+    return request.post<Api.Follow.FollowListData>({
+      url: 'follow/canteenList',
+      params
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 获取食堂关联的所以跟进
+  static canteenAll(first_id:number,  second_id?:number) {
+    return request.post<Api.Follow.FollowInfo[]>({
+      url: 'follow/canteenAll',
+      params:{first_id, second_id}
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 公司跟进详情
+  static companyInfo(id:number) {
+    return request.post<Api.Follow.FollowInfo>({
+      url: 'follow/companyInfo',
+      params:{id}
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 公司添加跟进
+  static companyFollow(params:Form.Follow) {
+    return request.post<Api.Http.BaseResponse>({
+      url: 'follow/companyAdd',
+      params
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 公司跟进列表
+  static companyList(params: Api.Common.PaginatingSearchParams) {
+    return request.post<Api.Follow.FollowListData>({
+      url: 'follow/companyList',
+      params
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
+
+  // 获取公司关联的所以跟进
+  static companyAll(first_id:number,  second_id?:number) {
+    return request.post<Api.Follow.FollowInfo[]>({
+      url: 'follow/companyAll',
+      params:{first_id, second_id}
+      // showErrorMessage: false // 不显示错误消息
+    })
+  }
 }

+ 1 - 1
web/src/components/core/base/art-logo/index.vue

@@ -1,7 +1,7 @@
 <!-- 系统logo -->
 <template>
   <div class="art-logo">
-    <img :style="logoStyle" src="@/assets/img/favicon.ico" alt="logo" />
+    <img :style="logoStyle" src="@/assets/img/favicon.ico" alt="logo" style="border-radius: 50%" />
   </div>
 </template>
 

+ 125 - 0
web/src/components/custom/ArtUploadImgs.vue

@@ -0,0 +1,125 @@
+<template>
+  <el-upload
+      :file-list="fileList"
+      :name="props.fileName"
+      :action="uploadServer"
+      list-type="picture-card"
+      :limit="props.limit"
+      :show-file-list="true"
+      :on-preview="handlePictureCardPreview"
+      :on-remove="handleRemove"
+      :before-upload="beforeUpload"
+      :on-success="handleSuccess"
+      :on-exceed="handleExceed"
+      :headers="{Authorization: useUserStore().accessToken}"
+      drag
+  >
+    <i class="el-icon-plus"></i>
+    <el-icon><Plus /></el-icon>
+    <template #tip>
+      <div style="color:red;font-size: 12px">支持拖拽上传,限 jpg、png、jpeg,最大{{ props.maxSizeMB }}M,最多{{ props.limit }}张</div>
+    </template>
+  </el-upload>
+  <!--    图片预览    -->
+  <el-image-viewer
+      v-if="picDialogVisible"
+      :url-list="[dialogImageUrl]"
+      show-progress
+      :initial-index="0"
+      @close="picDialogVisible = false"
+  />
+</template>
+
+<script setup lang="ts">
+  import {computed, ref} from 'vue'
+  import { useUserStore } from '@/store/modules/user'
+  import { Plus } from '@element-plus/icons-vue'
+  import {
+    ElMessage,
+    ElMessageBox,
+    UploadFile,
+    UploadFiles,
+    UploadProps,
+    UploadRawFile,
+    UploadUserFile
+  } from 'element-plus'
+  import {commonApi} from "@/api/commonApi";
+
+  interface Props {
+    maxSizeMB: number
+    limit: number
+    fileName: Api.ImgType
+    imgs: string[]
+  }
+
+  interface Emits {
+    (e: 'update:imgs', value: string[]): void
+  }
+
+  const props = defineProps<Props>()
+  const emit = defineEmits<Emits>()
+  const uploadServer = computed(() => import.meta.env.VITE_API_URL + import.meta.env.VITE_UPLOAD_URL)
+
+  const fileList = ref<UploadUserFile[]>([])
+  
+  watch(() => props.imgs, (value) => {
+    fileList.value = value.map((item) => {
+      return {
+        name: item.split('/').pop()?? '',
+        url: item
+      }
+    })
+    console.log(`%c fileList == `, 'background:#41b883 ; padding:1px; color:#fff', fileList.value);
+  }, { immediate: true })
+  
+  const dialogImageUrl = ref('')
+  const picDialogVisible = ref(false)
+
+  const handleRemove: UploadProps['onRemove'] = (uploadFile: UploadFile) => {
+    const response = uploadFile.response as {data:any}
+    let upName = response ? response.data.name : uploadFile.name
+    console.log(`%c upName == `, 'background:#41b883 ; padding:1px; color:#fff', upName);
+    commonApi.delImg(upName)
+    for (let i = 0; i < props.imgs.length; i++) {
+      if (props.imgs[i] === upName) {
+        props.imgs.splice(i, 1)
+        emit("update:imgs", props.imgs)
+        break
+      }
+    }
+
+  }
+
+  const beforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile):boolean => {
+    if (rawFile.size && rawFile.size > 1024 * 1024 * props.maxSizeMB) {
+      ElMessage.error(`上传图片大小不能超过${props.maxSizeMB}M`)
+      return false
+    }
+    if (!rawFile.type.startsWith('image/')) {
+      ElMessage.error('只能上传图片文件')
+      return false
+    }
+    return true
+  }
+
+  const handleSuccess: UploadProps['onSuccess'] = (response, uploadFile) => {
+    if (response.code === 200) {
+      props.imgs.push(response.data.name)
+      // fileList.value.push({name: response.data.name, url: response.data.url})
+      emit("update:imgs", props.imgs)
+      ElMessage.success('上传成功!')
+    } else {
+      fileList.value = fileList.value.filter((file) => file.name !== uploadFile.name)
+      ElMessage.error(response.msg)
+    }
+  }
+
+  const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
+    dialogImageUrl.value = uploadFile.url!
+    picDialogVisible.value = true
+  }
+
+  const handleExceed = () => {
+    ElMessage.warning(`最多只能上传${props.limit}张图片`)
+  }
+</script>

+ 14 - 88
web/src/components/custom/FollowDialog.vue

@@ -7,7 +7,7 @@
   >
     <ElForm ref="formRef" :model="formData" :rules="rules" label-width="auto">
       <ElFormItem :label="selectLabelArr[0]" prop="first_id">
-        <ElSelect v-model="formData.first_id" :empty-values="[0]" :value-on-clear="0">
+        <ElSelect v-model="formData.first_id" :empty-values="[0]" :value-on-clear="0" style="max-width: 300px">
           <ElOption
             v-for="item in selectList"
             :key="item.id"
@@ -17,7 +17,7 @@
         </ElSelect>
       </ElFormItem>
       <ElFormItem :label="selectLabelArr[1]" prop="second_id">
-        <ElSelect v-model="formData.second_id" :empty-values="[0]" :value-on-clear="0">
+        <ElSelect v-model="formData.second_id" :empty-values="[0]" :value-on-clear="0" style="max-width: 300px">
           <ElOption
               v-for="item in secondSelectList"
               :key="item.id"
@@ -27,33 +27,12 @@
         </ElSelect>
       </ElFormItem>
       <ElFormItem label="微信聊天记录" prop="chat_imgs">
-        <el-upload
-            :file-list="fileList"
-            name="follow"
-            :action="uploadServer"
-            list-type="picture-card"
-            :limit="12"
-            :show-file-list="false"
-            :on-preview="handlePictureCardPreview"
-            :on-remove="handleRemove"
-            :before-upload="beforeUpload"
-            :on-success="handleSuccess"
-            :headers="{Authorization: useUserStore().accessToken}"
-            drag
-        >
-          <i class="el-icon-plus"></i>
-          <el-icon><Plus /></el-icon>
-          <template #tip>
-            <div style="color:red">支持拖拽上传,限 jpg、png、jpeg 图片,最大{{maxFileSizeMB}}M,最多12张</div>
-          </template>
-        </el-upload>
-        <!--    图片预览    -->
-        <el-image-viewer
-            v-if="picDialogVisible"
-            :url-list="[dialogImageUrl]"
-            show-progress
-            :initial-index="0"
-            @close="picDialogVisible = false"
+        <ArtUploadImgs
+          :imgs="formData.chat_imgs"
+          :limit = "12"
+          file-name="follow"
+          :maxSizeMB="3"
+          @update:imgs="(value:string[]) => formData.chat_imgs = value"
         />
       </ElFormItem>
       <ElFormItem label="跟进详情" prop="detail">
@@ -72,19 +51,10 @@
 
 <script setup lang="ts">
   import {computed, ref} from 'vue'
-  import { useUserStore } from '@/store/modules/user'
-  import { Plus } from '@element-plus/icons-vue'
   import {
     ElMessage,
-    ElMessageBox,
-    UploadFile,
-    UploadFiles,
-    UploadProps,
-    UploadRawFile,
-    UploadUserFile
   } from 'element-plus'
   import type { FormInstance, FormRules } from 'element-plus'
-  import {commonApi} from "@/api/commonApi";
   import {followApi} from "@/api/followApi";
 
   interface Props {
@@ -103,12 +73,6 @@
 
   const props = defineProps<Props>()
   const emit = defineEmits<Emits>()
-  const uploadServer = computed(() => import.meta.env.VITE_API_URL + import.meta.env.VITE_UPLOAD_URL)
-
-  const fileList = ref<UploadUserFile[]>([])
-
-  const dialogImageUrl = ref('')
-  const picDialogVisible = ref(false)
 
   // 对话框显示控制
   const dialogVisible = computed({
@@ -150,47 +114,6 @@
     ],
   }
 
-  const handleRemove: UploadProps['onRemove'] = (uploadFile: UploadFile) => {
-    const response = uploadFile.response as {data:any}
-    let upName = response.data.name ? response.data.name : uploadFile.name
-    commonApi.delImg(upName)
-    for (let i = 0; i < formData.chat_imgs.length; i++) {
-      if (formData.chat_imgs[i] === upName) {
-        formData.chat_imgs.splice(i, 1)
-        break
-      }
-    }
-
-  }
-  const maxFileSizeMB = 3
-
-  const beforeUpload: UploadProps['beforeUpload'] = (rawFile: UploadRawFile):boolean => {
-    if (rawFile.size && rawFile.size > 1024 * 1024 * maxFileSizeMB) {
-      ElMessage.error(`上传图片大小不能超过${maxFileSizeMB}M`)
-      return false
-    }
-    if (!rawFile.type.startsWith('image/')) {
-      ElMessage.error('只能上传图片文件')
-      return false
-    }
-    return true
-  }
-
-  const handleSuccess: UploadProps['onSuccess'] = (response, uploadFile) => {
-    if (response.code === 200) {
-      formData.chat_imgs.push(response.data.name)
-      ElMessage.success('上传成功!')
-    } else {
-      fileList.value = fileList.value.filter((file) => file.name !== uploadFile.name)
-      ElMessage.error(response.msg)
-    }
-  }
-
-  const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
-    dialogImageUrl.value = uploadFile.url!
-    picDialogVisible.value = true
-  }
-
   const secondSelectList = computed(() => {
     if (formData.first_id) {
       let tmp = props.selectList.find(item => item.id === formData.first_id)
@@ -220,14 +143,17 @@
   // 提交表单
   const handleSubmit = async () => {
     if (!formRef.value) return
+    if (formData.detail === '<p><br></p>') {
+      ElMessage.error(`请输入跟进详情`)
+      return false
+    }
     await formRef.value.validate((valid) => {
       if (valid) {
-        followApi.schoolFollow(formData).then(() => {
+        let func = {"school":followApi.schoolFollow, "canteen":followApi.canteenFollow, "company":followApi.companyFollow}[props.type]
+        func(formData).then(() => {
           ElMessage.success('提交成功')
           Object.assign(formData, defalutValue)
           formData.chat_imgs = [];
-          fileList.value = []
-          console.log(`%c formData == `, 'background:#41b883 ; padding:1px; color:#fff', formData);
           dialogVisible.value = false
           emit('submit')
         })

+ 12 - 3
web/src/components/custom/FollowDrawer.vue

@@ -1,6 +1,7 @@
 <template>
   <ElCard
       shadow="never"
+      v-if="timelineData.length"
       v-for="(activity, index) in timelineData"
       :key="index"
       style="margin-bottom: 10px"
@@ -35,13 +36,19 @@
               <label>详情:</label>
             </el-col>
             <el-col :sm="22">
-              <ElCard shadow="never" v-html="follow.detail" style="padding: 10px;max-height: 200px;overflow-y: scroll"/>
+<!--              <ElCard shadow="never" v-html="follow.detail" style="padding: 10px;max-height: 200px;overflow-y: scroll"/>-->
+              <div v-html="follow.detail" style="padding: 10px;max-height: 200px;overflow-y: scroll"/>
             </el-col>
           </el-row>
         </el-col>
       </el-row>
     </ElCard>
   </ElCard>
+  <ElCard
+      v-else
+  >
+    <template #header><span style="font-weight: 400; color: #f59a23;">暂无数据</span> </template>
+  </ElCard>
 </template>
 
 <script setup lang="ts">
@@ -52,6 +59,7 @@
     type:Api.FollowTye
     first_id:number
     second_id:number
+    uid?:number // 这几个字段共同控制刷新
   }
 
   const props = defineProps<Props>()
@@ -77,10 +85,11 @@
   }
 
   watch(
-    () => [props.type, props.first_id, props.second_id],
+    () => [props.type, props.first_id, props.second_id, props.uid],
     async () => {
       timelineData.splice(0, timelineData.length)
-      followApi.schoolAll(props.first_id, props.second_id).then((res:any) => delRes(res)).catch((e) => {
+      let func = {"school":followApi.schoolAll, "canteen":followApi.canteenAll, "company":followApi.companyAll}[props.type]
+      func(props.first_id, props.second_id).then((res:any) => delRes(res)).catch((e: any) => {
         ElMessage.error('获取数据失败')
         console.error(`%c e == `, 'background:#41b883 ; padding:1px; color:#fff', e);
       })

+ 8 - 55
web/src/locales/langs/en.json

@@ -227,73 +227,18 @@
       "analysis": "Analysis",
       "ecommerce": "Ecommerce"
     },
-    "widgets": {
-      "title": "Components",
-      "iconList": "Icon List",
-      "iconSelector": "Icon Selector",
-      "imageCrop": "Image Crop",
-      "excel": "Excel Import Export",
-      "video": "Video Player",
-      "countTo": "Count To",
-      "wangEditor": "Wang Editor",
-      "watermark": "Watermark",
-      "contextMenu": "Context Menu",
-      "qrcode": "QR Code",
-      "drag": "Drag",
-      "textScroll": "Text Scroll",
-      "fireworks": "Fireworks",
-      "elementUI": "Component Overview"
-    },
-    "template": {
-      "title": "Template Center",
-      "chat": "Chat",
-      "cards": "Cards",
-      "banners": "Banners",
-      "charts": "Charts",
-      "map": "Map",
-      "calendar": "Calendar",
-      "pricing": "Pricing"
-    },
-    "article": {
-      "title": "Article Management",
-      "articleList": "Article List",
-      "articleDetail": "Article Detail",
-      "comment": "Comment",
-      "articlePublish": "Article Publish"
-    },
-    "result": {
-      "title": "Result Page",
-      "success": "Success",
-      "fail": "Fail"
-    },
     "exception": {
       "title": "Exception",
       "forbidden": "403",
       "notFound": "404",
       "serverError": "500"
     },
-    "examples": {
-      "title": "Feature Examples",
-      "tabs": "Tabs",
-      "tablesBasic": "Basic Tables",
-      "tables": "Advanced Tables",
-      "tablesTree": "Tree Table Layout",
-      "searchBar": "Search Form"
-    },
     "system": {
       "title": "System Settings",
       "user": "User Manage",
       "role": "Role Manage",
       "userCenter": "User Center",
       "menu": "Menu Manage",
-      "nested": "Nested Menu",
-      "menu1": "Menu 1",
-      "menu2": "Menu 2",
-      "menu21": "Menu 2-1",
-      "menu3": "Menu 3",
-      "menu31": "Menu 3-1",
-      "menu32": "Menu 3-2",
-      "menu321": "Menu 3-2-1",
       "flowList": "flow list",
       "flowDetail": "flow detail"
     },
@@ -306,6 +251,14 @@
       "add": "add school",
       "edit": "edit school"
     },
+    "canteen": {
+      "list": "canteen list",
+      "info": "canteen info",
+      "add": "add canteen",
+      "edit": "edit canteen",
+      "followInfo": "canteen follow info",
+      "follow": "canteen follow list"
+    },
     "safeguard": {
       "title": "Safeguard",
       "server": "Server"

+ 10 - 57
web/src/locales/langs/zh.json

@@ -219,85 +219,38 @@
       "analysis": "分析页",
       "ecommerce": "电子商务"
     },
-    "widgets": {
-      "title": "组件中心",
-      "iconList": "Icon 图标",
-      "iconSelector": "图标选择器",
-      "imageCrop": "图像裁剪",
-      "excel": "Excel 导入导出",
-      "video": "视频播放器",
-      "countTo": "数字滚动",
-      "wangEditor": "富文本编辑器",
-      "watermark": "水印",
-      "contextMenu": "右键菜单",
-      "qrcode": "二维码",
-      "drag": "拖拽",
-      "textScroll": "文字滚动",
-      "fireworks": "礼花",
-      "elementUI": "组件总览"
-    },
-    "template": {
-      "title": "模板中心",
-      "chat": "聊天",
-      "cards": "卡片",
-      "banners": "横幅",
-      "charts": "图表",
-      "map": "地图",
-      "calendar": "日历",
-      "pricing": "定价"
-    },
-    "article": {
-      "title": "文章管理",
-      "articleList": "文章列表",
-      "articleDetail": "文章详情",
-      "comment": "留言管理",
-      "articlePublish": "文章发布"
-    },
-    "result": {
-      "title": "结果页面",
-      "success": "成功页",
-      "fail": "失败页"
-    },
     "exception": {
       "title": "异常页面",
       "forbidden": "403",
       "notFound": "404",
       "serverError": "500"
     },
-    "examples": {
-      "title": "功能示例",
-      "tabs": "标签页",
-      "tablesBasic": "基础表格",
-      "tables": "高级表格",
-      "tablesTree": "左右布局表格",
-      "searchBar": "搜索表单"
-    },
     "system": {
       "title": "系统管理",
       "user": "用户管理",
       "role": "角色管理",
       "userCenter": "个人中心",
       "menu": "菜单管理",
-      "nested": "嵌套菜单",
-      "menu1": "菜单1",
-      "menu2": "菜单2",
-      "menu21": "菜单2-1",
-      "menu3": "菜单3",
-      "menu31": "菜单3-1",
-      "menu32": "菜单3-2",
-      "menu321": "菜单3-2-1",
       "flowList": "跟进记录",
       "flowDetail": "跟进详情"
     },
     "school": {
       "list": "校园信息",
       "relation": "校方关系",
-      "follow": "跟进记录",
-      "followInfo": "跟进详情",
+      "follow": "校园跟进记录",
+      "followInfo": "校园跟进详情",
       "info": "校园详情",
       "add": "新建校园",
       "edit": "编辑校园"
     },
+    "canteen": {
+      "list": "食堂信息",
+      "info": "食堂详情",
+      "add": "新建食堂",
+      "edit": "编辑食堂",
+      "followInfo": "食堂跟进详情",
+      "follow": "食堂跟进记录"
+    },
     "safeguard": {
       "title": "运维管理",
       "server": "服务器管理"

+ 99 - 0
web/src/router/routes/asyncRoutes.ts

@@ -162,6 +162,105 @@ export const asyncRoutes: AppRouteRecord[] = [
     ]
   },
   {
+    id: 13,
+    path: '/canteen',
+    name: 'CanteenManage',
+    component: RoutesAlias.Layout,
+    meta: {
+      title: 'menus.canteen.list',
+      icon: '&#xe7b9;'
+    },
+    children: [
+      {
+        id: 1301,
+        path: 'list',
+        name: 'canteen',
+        component: RoutesAlias.CanteenList,
+        meta: {
+          title: 'menus.canteen.list',
+          keepAlive: false,
+          authList: [
+            {
+              id: 130100,
+              title: '列表',
+              authMark: 'list'
+            },
+            {
+              id: 130101,
+              title: '新增',
+              authMark: 'add'
+            },
+            {
+              id: 130102,
+              title: '编辑',
+              authMark: 'edit'
+            },
+            {
+              id: 120103,
+              title: '删除',
+              authMark: 'delete'
+            },
+          ]
+        }
+      },
+      {
+        path: 'edit',
+        name: 'canteenEdit',
+        component: RoutesAlias.CanteenEdit,
+        meta: {
+          title: 'menus.canteen.edit',
+          isHide: true,
+          keepAlive: true,
+          activePath: '/canteen/list' // 激活菜单路径
+        }
+      },
+      {
+        path: 'info',
+        name: 'canteenInfo',
+        component: RoutesAlias.CanteenInfo,
+        meta: {
+          title: 'menus.canteen.info',
+          isHide: true,
+          keepAlive: true,
+          activePath: '/canteen/list' // 激活菜单路径
+        },
+      },
+      {
+        id: 1303,
+        path: 'follow',
+        name: 'canteenFollow',
+        component: RoutesAlias.CanteenFollow,
+        meta: {
+          title: 'menus.canteen.follow',
+          keepAlive: true,
+          authList: [
+            {
+              id: 130300,
+              title: '列表',
+              authMark: 'list'
+            },
+            {
+              id: 130301,
+              title: '编辑',
+              authMark: 'edit'
+            }
+          ]
+        }
+      },
+      {
+        path: 'follow/info',
+        name: 'canteenFollowInfo',
+        component: RoutesAlias.CanteenFollowInfo,
+        meta: {
+          title: 'menus.canteen.followInfo',
+          isHide: true,
+          keepAlive: true,
+          activePath: '/canteen/follow' // 激活菜单路径
+        }
+      },
+    ]
+  },
+  {
     id: 11,
     path: '/system',
     name: 'System',

+ 5 - 0
web/src/router/routesAlias.ts

@@ -19,4 +19,9 @@ export enum RoutesAlias {
   SchoolRelation = '/school/relation', // 学校关系
   SchoolFollow = '/school/follow', // 学校跟进
   SchoolFollowInfo = '/school/follow/info', // 学校跟进详情
+  CanteenList = '/canteen/list', // 食堂信息
+  CanteenEdit = '/canteen/edit', // 食堂编辑
+  CanteenInfo = '/canteen/info', // 食堂详情
+  CanteenFollow = '/canteen/follow', // 食堂跟进
+  CanteenFollowInfo = '/canteen/follow/info', // 食堂跟进详情
 }

+ 47 - 0
web/src/typings/api.d.ts

@@ -19,6 +19,7 @@ declare namespace Api {
   }
 
   type FollowTye = 'school'|'canteen'|'company'
+  type ImgType = 'avatar'|'follow'|'editor'|'canteen'
 
   /** 通用类型 */
   namespace Common {
@@ -214,6 +215,52 @@ declare namespace Api {
     }
   }
 
+  namespace Canteen {
+    interface Info {
+      id: number
+      school_id: number // 学校ID
+      school_name: string // 学校
+      company_name: string
+      name: string // 名称
+      stall_num: number // 档口数量
+      is_direct: 0|1 // 是否直营
+      stall_imgs: string[] // 档口照片
+      username: string // 食堂经理
+      phone: string // 手机号
+      weixin: string // 微信号
+      memo: string // 备注
+      last_user_id: number // 最后一次跟进人
+      last_user_name: string // 最后一次跟进人
+      last_date: string // 最后一次跟进时间
+    }
+
+    interface ListItem {
+      id: number
+      school_id: number // 学校ID
+      school_name: string // 学校
+      company_id: number
+      company_name: string
+      name: string // 名称
+      stall_num: number // 档口数量
+      is_direct: 0|1 // 是否直营
+      stall_imgs: string[] // 档口照片
+      username: string // 食堂经理
+      phone: string // 手机号
+      weixin: string // 微信号
+      memo: string // 备注
+      last_user_id: number // 最后一次跟进人
+      last_user_name: string // 最后一次跟进人
+      last_date: string // 最后一次跟进时间
+    }
+
+    interface ListData {
+      records: ListItem[]
+      current: number
+      size: number
+      total: number
+    }
+  }
+
   namespace Follow {
 
     interface FollowInfo {

+ 13 - 0
web/src/typings/form.d.ts

@@ -75,4 +75,17 @@ declare namespace Form {
     detail: string,
     type: string
   }
+
+  interface Canteen {
+    id: number
+    school_id: number // 学校ID
+    name: string // 名称
+    stall_num: number // 档口数量
+    is_direct: 0|1 // 是否直营
+    stall_imgs: string[] // 档口照片
+    username: string // 食堂经理
+    phone: string // 手机号
+    weixin: string // 微信号
+    memo: string // 备注
+  }
 }

+ 202 - 0
web/src/views/canteen/edit.vue

@@ -0,0 +1,202 @@
+<template>
+  <ElForm ref="formRef" :model="formData" :rules="rules" label-width="auto">
+    <el-row :gutter="20">
+      <el-col>
+        <ElFormItem label="食堂名称" prop="name">
+          <ElInput style="max-width: 400px" v-model="formData.name" maxlength="20" type="text" />
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="档口数量" prop="stall_num">
+          <ElInput style="max-width: 400px" v-model="formData.stall_num" type="number" />
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="是否直营" prop="is_direct">
+          <el-select style="max-width: 200px" v-model="formData.is_direct" placeholder="请选择">
+            <el-option
+              v-for="item in [
+                { label: '非直营', value: 0 },
+                { label: '直营', value: 1 }
+              ]"
+              :key="item.value"
+              :value="item.value"
+              :label="item.label"
+            />
+          </el-select>
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="学校(校区)" prop="school_id">
+          <ElSelect style="max-width: 200px" v-model="formData.school_id" placeholder="请选择" :empty-values="[0]" :value-on-clear="0">
+            <ElOption
+              v-for="item in selectList"
+              :key="item.id"
+              :value="item.id"
+              :label="item.name"
+            />
+          </ElSelect>
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="餐饮公司" prop="school_id">
+          <ElInput style="max-width: 400px" v-model="formData.school_id" type="number" />
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="食堂经理" prop="username">
+          <ElInput style="max-width: 400px" v-model="formData.username" maxlength="20" type="text" />
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="手机号" prop="phone">
+          <ElInput style="max-width: 400px" v-model="formData.phone" maxlength="20" type="text" />
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="微信号" prop="weixin">
+          <ElInput style="max-width: 400px" v-model="formData.weixin" maxlength="20" type="text" />
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="档口照片" prop="tall_imgs">
+          <ArtUploadImgs
+              :imgs="formData.stall_imgs"
+              :limit = "12"
+              file-name="canteen"
+              :maxSizeMB="3"
+              @update:imgs="(value:string[]) => formData.stall_imgs = value"
+          />
+        </ElFormItem>
+      </el-col>
+      <el-col>
+        <ElFormItem label="备注" prop="memo">
+          <ElInput rows="4" v-model="formData.memo" maxlength="255" type="textarea" />
+        </ElFormItem>
+      </el-col>
+      <el-col :span="24">
+        <ElFormItem label=" " prop="">
+          <ElButton type="primary" @click="handleSubmit">提交</ElButton>
+        </ElFormItem>
+      </el-col>
+    </el-row>
+  </ElForm>
+</template>
+
+<script setup lang="ts">
+  import type { FormInstance, FormRules } from 'element-plus'
+  import {canteenApi, canteenApi as Api} from '@/api/canteenApi'
+  import { onMounted } from 'vue'
+  import { router } from '@/router'
+  import { ElMessageBox } from 'element-plus'
+  import { RoutesAlias } from '@/router/routesAlias'
+
+  // 表单实例
+  const formRef = ref<FormInstance>()
+
+  const DefaultData = <Form.Canteen>{
+    id: 0,
+    school_id: 0,
+    name: '',
+    stall_num: 0,
+    is_direct: 0,
+    stall_imgs: [],
+    username: '',
+    phone: '',
+    weixin: '',
+    memo: '',
+    last_user_id: 0,
+    last_user_name: '',
+    last_date: ''
+  }
+  // 表单数据
+  const formData = reactive<Form.Canteen>({ ...DefaultData })
+
+  // 表单验证规则
+  const rules: FormRules = {
+    school_id: [{ required: true, message: '请输入学校ID', trigger: 'blur' }],
+    name: [
+      { required: true, message: '请输入名称', trigger: 'blur' },
+      { max: 20, message: '长度最多20个字符', trigger: 'blur' }
+    ],
+    stall_num: [{ required: true, message: '请输入档口数量', trigger: 'blur' }],
+    username: [
+      { required: true, message: '请输入食堂经理', trigger: 'blur' },
+      { max: 20, message: '长度最多20个字符', trigger: 'blur' }
+    ],
+    phone: [
+      { required: true, message: '请输入手机号', trigger: 'blur' },
+      {
+        pattern: /^1[3456789]\d{9}$/,
+        message: '请输入正确的手机号码',
+        trigger: 'blur'
+      }
+    ],
+    weixin: [
+      { required: true, message: '请输入微信号', trigger: 'blur' },
+      { max: 20, message: '长度最多20个字符', trigger: 'blur' }
+    ],
+    memo: [{ max: 255, message: '长度最多255个字符', trigger: 'blur' }]
+  }
+
+  const route = useRoute()
+  let id = 0
+
+  // 初始化表单数据
+  const initFormData = () => {
+    if (id == 0) {
+      Object.assign(formData, DefaultData)
+    } else {
+      Api.info(id).then((res) => {
+        Object.assign(formData, res)
+      })
+    }
+  }
+
+  const selectList = ref<Api.Common.SelectRelationInfo[]>([])
+  const getSelectList = async () => {
+    const data = await canteenApi.selectList()
+    selectList.value = data
+  }
+
+  onMounted(() => {
+    id = route.query.id ? parseInt(route.query.id as string) : 0
+    getSelectList()
+    initFormData()
+    nextTick(() => {
+      formRef.value?.clearValidate()
+    })
+  })
+
+  const afterOk = () => {
+    ElMessageBox.confirm('操作成功,是否返回列表', 'Success', {
+      confirmButtonText: '返回列表',
+      cancelButtonText: '留在此页',
+      type: 'info'
+    })
+      .then(() => {
+        router.push({ path: RoutesAlias.CanteenList })
+      })
+      .catch(() => {
+        // ... do nothing
+      })
+  }
+
+  // 提交表单
+  const handleSubmit = async () => {
+    if (!formRef.value) return
+    await formRef.value.validate((valid) => {
+      if (valid) {
+        if (id == 0) {
+          Api.add(formData).then(() => {
+            afterOk()
+          })
+        } else {
+          Api.edit(formData).then(() => {
+            afterOk()
+          })
+        }
+      }
+    })
+  }
+</script>

+ 210 - 0
web/src/views/canteen/follow/index.vue

@@ -0,0 +1,210 @@
+<!-- 学校关系管理 -->
+<!-- art-full-height 自动计算出页面剩余高度 -->
+<!-- art-table-card 一个符合系统样式的 class,同时自动撑满剩余高度 -->
+<!-- 更多 useTable 使用示例请移步至 功能示例 下面的 高级表格示例 -->
+<template>
+  <div class="user-page art-full-height">
+    <!-- 搜索栏 -->
+    <UserSearch
+      v-model="searchForm"
+      @search="handleSearch"
+      @reset="resetSearchParams"
+      :selectList="selectList"
+    ></UserSearch>
+
+    <ElCard class="art-table-card" shadow="never">
+      <!-- 表格头部 -->
+      <ArtTableHeader v-model:columns="columnChecks" @refresh="refreshData">
+        <template #left>
+          <ElButton type="primary" @click="follow()" v-ripple v-auth="130301">新增跟进记录</ElButton>
+        </template>
+      </ArtTableHeader>
+
+      <!-- 表格 -->
+      <ArtTable
+        :loading="loading"
+        :data="data"
+        :columns="columns"
+        :pagination="pagination"
+        @pagination:size-change="handleSizeChange"
+        @pagination:current-change="handleCurrentChange"
+      >
+      </ArtTable>
+
+      <!--   跟进弹窗   -->
+      <FollowDialog
+        v-model:visible="followDialogVisible"
+        :user-data="currentUserData"
+        :type="'canteen'"
+        :first_id="0"
+        :second_id="0"
+        :selectList="selectList"
+        @submit="handleDialogSubmit"
+      />
+    </ElCard>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
+  import {ElMessageBox, ElMessage, ElTag, ElImage, ElButton} from 'element-plus'
+  import { useTable } from '@/composables/useTable'
+  import UserSearch from './modules/user-search.vue'
+  import { followApi } from '@/api/followApi'
+  import {RoutesAlias} from "@/router/routesAlias";
+  import {router} from "@/router";
+  import {canteenApi} from "@/api/canteenApi";
+
+  defineOptions({ name: 'CanteenFollow' })
+
+  type ListItem = Api.Canteen.ListItem
+
+  // 弹窗相关
+  const dialogVisible = ref(false)
+  const followDialogVisible = ref(false)
+  const currentUserData = ref<Partial<ListItem>>({})
+
+  // 搜索表单
+  const searchForm = ref({
+    name: '',
+    phone: '',
+    school_id: parseInt(<string>useRoute().query.school_id)  || '',
+  })
+
+  const selectList = ref<Api.Common.SelectRelationInfo[]>([])
+  const getSelectList = async () => {
+    const data = await canteenApi.selectList()
+    selectList.value = data
+  }
+  getSelectList()
+
+  const {
+    columns,
+    columnChecks,
+    data,
+    loading,
+    pagination,
+    getData,
+    searchParams,
+    resetSearchParams,
+    handleSizeChange,
+    handleCurrentChange,
+    refreshData
+  } = useTable<Api.Follow.FollowInfo>({
+    // 核心配置
+    core: {
+      apiFn: followApi.canteenList,
+      apiParams: {
+        current: 1,
+        size: 20,
+        ...searchForm.value
+      },
+      // 排除 apiParams 中的属性
+      excludeParams: ['daterange'],
+      columnsFactory: () => [
+        { prop:'first_name', label:'校园(园区)' },
+        { prop:'second_name', label:'食堂' },
+        { prop:'position', label:'职位' },
+        { prop:'phone', label:'手机号' },
+        { prop:'weixin', label:'微信号' },
+        { prop:'chat_imgs', label:'微信聊天记录', formatter: (row) => {
+            return h(ElImage, {
+              src: row.chat_imgs[0],
+              previewSrcList: row.chat_imgs,
+              showProgress: true,
+              fit: "cover",
+              title: '点击预览全部图片',
+              style: {"max-width": "50px", "max-height": "50px"},
+              // 图片预览是否插入至 body 元素上,用于解决表格内部图片预览样式异常
+              previewTeleported: true
+            })
+          }
+        },
+        { prop:'user_name', label:'跟进人员' },
+        { prop:'create_date', label:'跟进时间' },
+        {
+          prop: 'operation',
+          label: '操作',
+          width: 120,
+          fixed: 'right', // 固定列
+          formatter: (row) =>
+            h('div', [
+              h(ArtButtonTable, {
+                type: 'view',
+                onClick: () => view(row.id)
+              }),
+            ])
+        }
+      ]
+    }
+  })
+
+  /**
+   * 查看
+   */
+  const view = (id: number): void => {
+    router.push({
+      path: RoutesAlias.CanteenFollowInfo,
+      query: {
+        id: id
+      }
+    })
+  }
+
+  /**
+   * 搜索处理
+   * @param params 参数
+   */
+  const handleSearch = (params: Record<string, any>) => {
+    Object.assign(searchParams, { ...params })
+    getData()
+  }
+
+    /**
+   * 显示跟进弹窗
+   */
+  const follow = (): void => {
+    nextTick(() => {
+      followDialogVisible.value = true
+    })
+  }
+
+  /**
+   * 处理弹窗提交事件
+   */
+  const handleDialogSubmit = async () => {
+    try {
+      dialogVisible.value = false
+      followDialogVisible.value = false
+      currentUserData.value = {}
+      // 延迟更新 不然数据可能没更新
+      setTimeout(() => {
+        refreshData()
+      }, 1000)
+    } catch (error) {
+      console.error('提交失败:', error)
+    }
+  }
+</script>
+
+<style lang="scss" scoped>
+  .user-page {
+    :deep(.user) {
+      .avatar {
+        width: 40px;
+        height: 40px;
+        margin-left: 0;
+        border-radius: 6px;
+      }
+
+      > div {
+        margin-left: 10px;
+
+        .user-name {
+          font-weight: 500;
+          color: var(--art-text-gray-800);
+        }
+      }
+    }
+  }
+</style>

+ 87 - 0
web/src/views/canteen/follow/info.vue

@@ -0,0 +1,87 @@
+<template>
+  <div>
+    <el-row :gutter="20" class="detail">
+
+      <el-col :span="24">
+        <h3>基本信息</h3>
+      </el-col>
+
+      <el-col :span="24">
+        <label>学校(校区):</label> <span>{{ info.first_name }}</span>
+      </el-col>
+
+      <el-col :span="24">
+        <label>食堂:</label>
+        <el-table :data="[{second_name: info.second_name, weixin: info.weixin, phone: info.phone, user_name: info.user_name}]" style="width: 60%; margin-top: 10px">
+          <el-table-column prop="second_name" label="食堂"  />
+          <el-table-column prop="user_name" label="姓名"  />
+          <el-table-column prop="phone" label="手机号" />
+          <el-table-column prop="weixin" label="微信号" />
+        </el-table>
+      </el-col>
+
+      <el-col :span=24>
+        <label>微信聊天记录:</label>
+        <div style="margin-top: 10px">
+          <el-image
+              style="max-width: 100px; max-height: 180px; margin-right: 10px"
+              v-for="(url, index) in info.chat_imgs"
+              :key="index"
+              :src="url"
+              show-progress
+              :initial-index="index"
+              :preview-src-list="info.chat_imgs"
+              fit="cover"
+          />
+        </div>
+      </el-col>
+
+      <el-col :span="24">
+        <label>跟进记录详情:</label>
+        <ElCard shadow="never" v-html="info.detail" style="padding: 10px; margin-top: 20px"/>
+      </el-col>
+
+    </el-row>
+  </div>
+</template>
+
+<script setup lang="ts">
+// 初始化表单数据
+import {followApi} from "@/api/followApi";
+import {onMounted} from "vue";
+
+const DefaultData = <Api.Follow.FollowInfo>{
+  id: 0,
+  chat_imgs: [],
+  detail: '',
+  create_date: '',
+  first_name: '', //
+  second_name: '', //
+  user_name: '', //
+  avatar:'',
+  phone: '', // 手机号,
+  weixin: '', // 微信号,
+  position: '', // 职位,
+}
+const info = reactive<Api.Follow.FollowInfo>({ ...DefaultData })
+onMounted(() => {
+  followApi.canteenInfo(parseInt(useRoute().query.id as string)).then((res) => {
+    Object.assign(info, res)
+    console.log(`%c info == `, 'background:#41b883 ; padding:1px; color:#fff', info);
+  })
+})
+</script>
+
+<style scoped>
+.detail {
+  padding-top: 20px;
+  font-size: 14px;
+  .el-col {
+    margin-bottom: 30px;
+    label {
+      font-weight: bold;
+      margin-right: 10px;
+    }
+  }
+}
+</style>

+ 80 - 0
web/src/views/canteen/follow/modules/user-search.vue

@@ -0,0 +1,80 @@
+<template>
+  <ArtSearchBar
+      ref="searchBarRef"
+      v-model="formData"
+      :items="formItems"
+      :selectList="selectList"
+      :rules="rules"
+      @reset="handleReset"
+      @search="handleSearch"
+  >
+  </ArtSearchBar>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, h } from 'vue'
+
+interface Props {
+  modelValue: Record<string, any>
+  selectList: Api.Common.SelectRelationInfo[]
+}
+interface Emits {
+  (e: 'update:modelValue', value: Record<string, any>): void
+  (e: 'search', params: Record<string, any>): void
+  (e: 'reset'): void
+}
+const props = defineProps<Props>()
+const emit = defineEmits<Emits>()
+
+// 表单数据双向绑定
+const searchBarRef = ref()
+const formData = computed({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+// 校验规则
+const rules = {
+  // name: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
+}
+
+const props1 = {
+  checkStrictly: true
+}
+
+// 表单配置
+const formItems = computed(() => [
+  {
+    label: '学校',
+    key: 'school',
+    type: 'cascader',
+    props: {
+      options: props.selectList.map(item => ({
+        value: item.id,
+        label: item.name,
+        children: item.children.map(item => ({
+          value: item.id,
+          label: item.name
+        }))
+      })),
+      style: { width: '280px' },
+      filterable: true,
+      placeholder: '可搜索',
+      clearable: true,
+      props: props1
+    }
+  },
+])
+
+// 事件
+function handleReset() {
+  console.log('重置表单')
+  emit('reset')
+}
+
+async function handleSearch() {
+  await searchBarRef.value.validate()
+  emit('search', formData.value)
+  console.log('表单数据', formData.value)
+}
+</script>

+ 99 - 0
web/src/views/canteen/info.vue

@@ -0,0 +1,99 @@
+<template>
+  <div>
+    <ElRow class="detail">
+
+      <el-col>
+        <label>食堂名称:</label> <span>{{ info.name }}</span>
+      </el-col>
+
+      <el-col>
+        <label>学校(校区):</label> <span>{{ info.school_name }}</span>
+      </el-col>
+
+      <el-col>
+        <label>档口数量:</label> <span>{{ info.stall_num }}</span>
+      </el-col>
+
+      <el-col>
+        <label>是否直营:</label> <span>{{ info.is_direct ? '直营' : '非直营' }}</span>
+      </el-col>
+
+      <el-col>
+        <label>餐饮公司:</label> <span>{{ info.company_name }}</span>
+      </el-col>
+
+      <el-col>
+        <label>食堂经理:</label> <span>{{ info.username }}</span>
+      </el-col>
+
+      <el-col>
+        <label>手机号:</label> <span>{{ info.phone }}</span>
+      </el-col>
+
+      <el-col>
+        <label>微信号:</label> <span>{{ info.weixin }}</span>
+      </el-col>
+
+      <el-col>
+        <label>档口照片:</label>
+        <el-image
+            v-if="info.stall_imgs.length"
+            ref="imageRef"
+            style="width: 60px; height: 60px"
+            :src="info.stall_imgs[0]"
+            show-progress
+            :preview-src-list="info.stall_imgs"
+            fit="cover"
+        />
+      </el-col>
+
+
+    </ElRow>
+  </div>
+</template>
+
+<script setup lang="ts">
+// 初始化表单数据
+import {onMounted} from "vue";
+import {canteenApi} from "@/api/canteenApi";
+import {ElImage} from "element-plus";
+
+const DefaultData = <Api.Canteen.Info>{
+  id:0,
+  school_id: 0,
+  school_name: "",
+  company_name: "",
+  name: "",
+  stall_num: 0,
+  is_direct: 0,
+  stall_imgs: [],
+  username: "",
+  phone: "",
+  weixin: "",
+  memo: "",
+  last_user_id: 0,
+  last_user_name: '',
+  last_date: ''
+}
+const info = reactive<Api.Canteen.Info>({ ...DefaultData })
+onMounted(() => {
+  canteenApi.info(parseInt(useRoute().query.id as string)).then((res) => {
+    Object.assign(info, res)
+    console.log(`%c info == `, 'background:#41b883 ; padding:1px; color:#fff', info);
+  })
+})
+</script>
+
+<style scoped>
+.detail {
+  padding-top: 20px;
+  font-size: 14px;
+  .el-col {
+    margin-bottom: 30px;
+    label {
+      font-weight: bold;
+      margin-right: 10px;
+    }
+  }
+}
+</style>

+ 420 - 0
web/src/views/canteen/list/index.vue

@@ -0,0 +1,420 @@
+<!-- 用户管理 -->
+<!-- art-full-height 自动计算出页面剩余高度 -->
+<!-- art-table-card 一个符合系统样式的 class,同时自动撑满剩余高度 -->
+<!-- 更多 useTable 使用示例请移步至 功能示例 下面的 高级表格示例 -->
+<template>
+  <div class="user-page art-full-height">
+    <!-- 搜索栏 -->
+    <UserSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" :school-list="selectList" :company-list="[]"></UserSearch>
+
+    <!--   跟进弹窗   -->
+    <FollowDialog
+        v-model:visible="followDialogVisible"
+        :user-data="currentRow"
+        :type="'canteen'"
+        :first_id="currentRow.school_id"
+        :second_id="currentRow.id"
+        :selectList="selectList"
+        @submit="handleDialogSubmit"
+    />
+
+    <ElCard class="art-table-card" shadow="never">
+      <!-- 表格头部 -->
+      <ArtTableHeader v-model:columns="columnChecks" @refresh="refreshData">
+        <template #left>
+          <ElButton type="primary" @click="edit()" v-ripple v-auth="130101">新增食堂</ElButton>
+        </template>
+      </ArtTableHeader>
+
+      <!-- 表格 -->
+      <ArtTable
+          :loading="loading"
+          :data="data"
+          :columns="columns"
+          :pagination="pagination"
+          @pagination:size-change="handleSizeChange"
+          @pagination:current-change="handleCurrentChange"
+      >
+        <template #person_num="scope">
+          <ElInput
+              v-model="scope.row.person_num"
+              placeholder="scope.row.person_num"
+              @blur="doUpdateAttr(scope)"
+          />
+        </template>
+
+        <template #is_eleme_in_school="scope">
+          <ElSwitch
+              v-model="scope.row.is_eleme_in_school"
+              @change="doUpdateAttr(scope)"
+              :active-value="1"
+              :inactive-value="0"
+              :before-change="() => isMounted"
+          />
+        </template>
+
+        <template #is_eleme_out_school="scope">
+          <ElSwitch v-model="scope.row.is_eleme_out_school" @change="doUpdateAttr(scope)"/>
+        </template>
+
+        <template #is_meituan_in_school="scope">
+          <ElSwitch v-model="scope.row.is_meituan_in_school" @change="doUpdateAttr(scope)"/>
+        </template>
+
+        <template #is_meituan_out_school="scope">
+          <ElSwitch v-model="scope.row.is_meituan_out_school" @change="doUpdateAttr(scope)"/>
+        </template>
+      </ArtTable>
+
+    </ElCard>
+
+    <el-drawer
+        v-model="drawer"
+        direction="rtl"
+        size="60%"
+    >
+      <template #header>
+        <span style="font-size: 20px; font-weight: bold;">{{ currentRow.name }}</span>
+      </template>
+      <ElRow>
+        <ElCol :sm="8">
+          <ElRow class="detail">
+
+            <el-col>
+              <label>学校(校区):</label> <span>{{ currentRow.school_name }}</span>
+            </el-col>
+
+            <el-col>
+              <label>档口数量:</label> <span>{{ currentRow.stall_num }}</span>
+            </el-col>
+
+            <el-col>
+              <label>是否直营:</label> <span>{{ currentRow.is_direct ? '直营' : '非直营' }}</span>
+            </el-col>
+
+            <el-col>
+              <label>餐饮公司:</label> <span>{{ currentRow.company_name }}</span>
+            </el-col>
+
+            <el-col>
+              <label>食堂经理:</label> <span>{{ currentRow.username }}</span>
+            </el-col>
+
+            <el-col>
+              <label>手机号:</label> <span>{{ currentRow.phone }}</span>
+            </el-col>
+
+            <el-col>
+              <label>微信号:</label> <span>{{ currentRow.weixin }}</span>
+            </el-col>
+
+            <el-col>
+              <label>档口照片:</label>
+              <el-image
+                  v-if="currentRow.stall_imgs.length"
+                  ref="imageRef"
+                  style="width: 60px; height: 60px"
+                  :src="currentRow.stall_imgs[0]"
+                  show-progress
+                  :preview-src-list="currentRow.stall_imgs"
+                  fit="cover"
+              />
+            </el-col>
+
+
+          </ElRow>
+        </ElCol>
+        <ElCol :sm="16">
+          <FollowDrawer :first_id="currentRow.school_id" :second_id="currentRow.id" type="canteen" :uid="drawerUid"/>
+        </ElCol>
+      </ElRow>
+    </el-drawer>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
+import {ElMessageBox, ElMessage, ElTag, ElImage, ElButton, ElInput} from 'element-plus'
+import {useTable} from '@/composables/useTable'
+import {canteenApi} from '@/api/canteenApi'
+import UserSearch from './user-search.vue'
+import {useUserStore} from '@/store/modules/user'
+import EmojiText from '@utils/ui/emojo'
+import {router} from '@/router'
+import {RoutesAlias} from '@/router/routesAlias'
+import {schoolRelationApi} from "@/api/schoolRelationApi";
+
+defineOptions({name: 'User'})
+
+const {list} = canteenApi
+const drawer = ref(false)
+const followDialogVisible = ref(false)
+
+// 搜索表单
+const searchForm = ref({
+  name: '',
+  is_cooperate: -1,
+  address: []
+})
+
+const defaultValue = <Api.Canteen.ListItem>{
+  id:0,
+  school_id: 0,
+  school_name: "",
+  company_id: 0,
+  company_name: "",
+  name: "",
+  stall_num: 0,
+  is_direct: 0,
+  stall_imgs: [],
+  username: "",
+  phone: "",
+  weixin: "",
+  memo: "",
+  last_user_id: 0,
+  last_user_name: '',
+  last_date: ''
+}
+
+const currentRow = ref<Api.Canteen.ListItem>({...defaultValue})
+
+const selectList = ref<Api.Common.SelectRelationInfo[]>([])
+const getSelectList = async () => {
+  const data = await canteenApi.selectList()
+  selectList.value = data
+}
+getSelectList()
+
+const drawerUid = ref(0)
+
+/**
+ * 处理弹窗提交事件
+ */
+const handleDialogSubmit = async () => {
+  followDialogVisible.value = false
+  drawerUid.value++
+}
+
+const {
+  columns,
+  columnChecks,
+  data,
+  loading,
+  pagination,
+  getData,
+  searchParams,
+  resetSearchParams,
+  handleSizeChange,
+  handleCurrentChange,
+  refreshData
+} = useTable<Api.Canteen.ListItem>({
+  // 核心配置
+  core: {
+    apiFn: list,
+    apiParams: {
+      current: 1,
+      size: 20,
+      ...searchForm.value
+    },
+    // 排除 apiParams 中的属性
+    excludeParams: [],
+    columnsFactory: () => [
+      {
+        prop: 'name', label: '食堂名称', formatter: (row) => {
+          return h(ElButton, {
+            type: 'primary',
+            link: true,
+            onClick: () => showDrawer(row),
+            style: {"text-decoration": 'underline'}
+          }, () => row.name)
+        }
+      },
+      { prop:'school_name', label:'学校' },
+      { prop:'stall_num', label:'档口数量' },
+      { prop:'is_direct', label:'是否直营', formatter: (row) => {
+        return h(ElTag, { type: row.is_direct ? 'success' : 'danger' }, () => row.is_direct ? '是' : '不是')
+      } },
+      { prop:'stall_imgs', label:'档口照片', formatter: (row) => {
+        if (row.stall_imgs.length > 0) {
+          return h(ElImage, {
+            src: row.stall_imgs[0],
+            previewSrcList: row.stall_imgs,
+            showProgress: true,
+            fit: "cover",
+            title: '点击预览全部图片',
+            style: {"max-width": "50px", "max-height": "50px"},
+            // 图片预览是否插入至 body 元素上,用于解决表格内部图片预览样式异常
+            previewTeleported: true
+          })
+        } else {
+          return ''
+        }
+      }},
+      { prop:'username', label:'食堂经理' },
+      { prop:'phone', label:'手机号' },
+      { prop:'weixin', label:'微信号' },
+      { prop:'memo', label:'备注', showOverflowTooltip:true },
+      { prop:'last_user_name', label:'最后一次跟进人' },
+      { prop:'last_date', label:'最后一次跟进时间' },
+      {
+        prop: '', label: '跟进记录', formatter: (row) => {
+          return h(ElButton, {
+            type: 'primary',
+            onClick: () => follow(row),
+          }, () => '跟进')
+        }
+      },
+      {
+        prop: 'operation',
+        label: '操作',
+        width: 120,
+        fixed: 'right', // 固定列
+        formatter: (row) =>
+            h('div', [
+              h(ArtButtonTable, {
+                type: 'view',
+                onClick: () => view(row.id)
+              }),
+              useUserStore().checkAuth(130102) && h(ArtButtonTable, {
+                type: 'edit',
+                onClick: () => edit(row.id)
+              }),
+              useUserStore().checkAuth(130203) &&
+              h(ArtButtonTable, {
+                type: 'delete',
+                onClick: () => deleteUser(row.id)
+              })
+            ])
+      }
+    ]
+  }
+})
+
+// TODO:不知道为什么初始化会触发switch的change事件
+const isMounted = ref(false)
+onMounted(() => {
+  setTimeout(() => {
+    isMounted.value = true
+  }, 1000)
+})
+
+const doUpdateAttr = (scope: any) => {
+  console.log(`%c scope == `, 'background:#41b883 ; padding:1px; color:#fff', scope)
+  if (!scope.row.id || !isMounted.value) {
+    return
+  }
+  canteenApi
+      .updateAttr({id: scope.row.id, attr: scope.prop, value: scope.row[scope.prop]})
+      .then(() => {
+        ElMessage.success(`${EmojiText[200]} 修改成功`)
+      })
+}
+
+/**
+ * 搜索处理
+ * @param params 参数
+ */
+const handleSearch = (params: Record<string, any>) => {
+  Object.assign(searchParams, {...params})
+  getData()
+}
+
+/**
+ * 编辑
+ */
+const edit = (id?: number): void => {
+  router.push({
+    path: RoutesAlias.CanteenEdit,
+    query: {
+      id: id
+    }
+  })
+}
+
+/**
+ * 显示跟进弹窗
+ */
+const follow = (row: Api.Canteen.ListItem): void => {
+  currentRow.value = row || {}
+  nextTick(() => {
+    followDialogVisible.value = true
+  })
+}
+
+/**
+ * 查看
+ */
+const view = (id: number): void => {
+  router.push({
+    path: RoutesAlias.CanteenInfo,
+    query: {
+      id: id
+    }
+  })
+}
+
+const showDrawer = (row: Api.Canteen.ListItem): void => {
+  drawer.value = true;
+  currentRow.value = row
+}
+
+const showContact = (row: Api.Canteen.ListItem):void => {
+  router.push({
+    path: RoutesAlias.SchoolRelation,
+    query: {
+      school_id: row.id
+    }
+  })
+}
+
+/**
+ * 删除
+ */
+const deleteUser = (id: number): void => {
+  ElMessageBox.confirm(`确定要删除该食堂吗?`, '删除食堂', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'error'
+  }).then(() => {
+      canteenApi.delete({id: id}).then(() => {
+      ElMessage.success(`${EmojiText[200]} 删除成功`)
+      setTimeout(() => {
+        getData()
+      }, 1000)
+    })
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.user-page {
+  :deep(.user) {
+    .avatar {
+      width: 40px;
+      height: 40px;
+      margin-left: 0;
+      border-radius: 6px;
+    }
+
+    > div {
+      margin-left: 10px;
+
+      .user-name {
+        font-weight: 500;
+        color: var(--art-text-gray-800);
+      }
+    }
+  }
+}
+.detail {
+  //padding-top: 20px;
+  font-size: 14px;
+  .el-col {
+    margin-bottom: 30px;
+    label {
+      font-weight: bold;
+      margin-right: 5px;
+    }
+  }
+}
+</style>

+ 92 - 0
web/src/views/canteen/list/user-search.vue

@@ -0,0 +1,92 @@
+<template>
+  <ArtSearchBar
+    ref="searchBarRef"
+    v-model="formData"
+    :items="formItems"
+    :rules="rules"
+    @reset="handleReset"
+    @search="handleSearch"
+  >
+  </ArtSearchBar>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, onMounted, h } from 'vue'
+  import { cityJson } from '@/router/utils/city'
+
+  interface Props {
+    modelValue: Record<string, any>
+    schoolList: Api.Common.SelectRelationInfo[]
+    companyList: Api.Common.SelectInfo[]
+  }
+  interface Emits {
+    (e: 'update:modelValue', value: Record<string, any>): void
+    (e: 'search', params: Record<string, any>): void
+    (e: 'reset'): void
+  }
+  const props = defineProps<Props>()
+  const emit = defineEmits<Emits>()
+
+  // 表单数据双向绑定
+  const searchBarRef = ref()
+  const formData = computed({
+    get: () => props.modelValue,
+    set: (val) => emit('update:modelValue', val)
+  })
+
+  // 校验规则
+  const rules = {
+    // name: [{ required: true, message: '请输入用户名', trigger: 'blur' }]
+  }
+
+  const props1 = {
+    checkStrictly: true
+  }
+
+  // 表单配置
+  const formItems = computed(() => [
+    {
+      label: '食堂名称',
+      key: 'name',
+      type: 'input',
+      placeholder: '请输入食堂名称',
+      clearable: true
+    },
+    {
+      label: '餐饮公司',
+      key: 'company_id',
+      type: 'select',
+      filterable: true,
+      props: {
+        options: props.companyList.map((item) => ({
+          label: item.name,
+          value: item.id
+        }))
+      }
+    },
+    {
+      label: '学校',
+      key: 'school_id',
+      type: 'select',
+      filterable: true,
+      props: {
+        options: props.schoolList.map((item) => ({
+          label: item.name,
+          value: item.id
+        }))
+      }
+    }
+  ])
+
+  // 事件
+  function handleReset() {
+    console.log('重置表单')
+    emit('reset')
+  }
+
+  async function handleSearch() {
+    await searchBarRef.value.validate()
+    emit('search', formData.value)
+    console.log('表单数据', formData.value)
+  }
+</script>

+ 14 - 14
web/src/views/school/follow/index.vue

@@ -64,10 +64,6 @@
   const followDialogVisible = ref(false)
   const currentUserData = ref<Partial<SchoolContactItem>>({})
 
-  // 选中行
-  const selectedRows = ref<SchoolContactItem[]>([])
-
-
   // 搜索表单
   const searchForm = ref({
     name: '',
@@ -112,16 +108,20 @@
         { prop:'phone', label:'手机号' },
         { prop:'weixin', label:'微信号' },
         { prop:'chat_imgs', label:'微信聊天记录', formatter: (row) => {
-            return h(ElImage, {
-              src: row.chat_imgs[0],
-              previewSrcList: row.chat_imgs,
-              showProgress: true,
-              fit: "cover",
-              title: '点击预览全部图片',
-              style: {"max-width": "50px", "max-height": "50px"},
-              // 图片预览是否插入至 body 元素上,用于解决表格内部图片预览样式异常
-              previewTeleported: true
-            })
+          if (row.chat_imgs.length > 0) {
+              return h(ElImage, {
+                src: row.chat_imgs[0],
+                previewSrcList: row.chat_imgs,
+                showProgress: true,
+                fit: "cover",
+                title: '点击预览全部图片',
+                style: {"max-width": "50px", "max-height": "50px"},
+                // 图片预览是否插入至 body 元素上,用于解决表格内部图片预览样式异常
+                previewTeleported: true
+              })
+            } else {
+              return '';
+            }
           }
         },
         { prop:'user_name', label:'跟进人员' },

+ 4 - 10
web/src/views/school/list/index.vue

@@ -152,7 +152,7 @@
           </ElRow>
         </ElCol>
         <ElCol :sm="14">
-          <FollowDrawer :first_id="currentRow.id" :second_id="0" type="school"/>
+          <FollowDrawer :first_id="currentRow.id" :second_id="0" type="school" :uid="drawerUid"/>
         </ElCol>
       </ElRow>
     </el-drawer>
@@ -216,16 +216,14 @@ const getSelectList = async () => {
 }
 getSelectList()
 
+const drawerUid = ref(0)
+
 /**
  * 处理弹窗提交事件
  */
 const handleDialogSubmit = async () => {
   followDialogVisible.value = false
-  Object.assign(currentRow.value, {...detaltValue})
-  // // 延迟更新 不然数据可能没更新
-  // setTimeout(() => {
-  //   refreshData()
-  // }, 1000)
+  drawerUid.value++
 }
 
 const {
@@ -424,10 +422,6 @@ const handleSearch = (params: Record<string, any>) => {
  * 编辑
  */
 const edit = (id?: number): void => {
-  // if (worktabStore.getTab(RoutesAlias.SchoolEdit)) {
-  //   console.log(`%c RoutesAlias.SchoolEdit == `, 'background:#41b883 ; padding:1px; color:#fff', RoutesAlias.SchoolEdit);
-  //   worktabStore.removeTab(RoutesAlias.SchoolEdit)
-  // }
   router.push({
     path: RoutesAlias.SchoolEdit,
     query: {

+ 4 - 3
web/src/views/school/relation/index.vue

@@ -93,7 +93,7 @@
           </ElRow>
         </ElCol>
         <ElCol :sm="16">
-          <FollowDrawer :first_id="currentRow.school_id" :second_id="currentRow.id" type="school"/>
+          <FollowDrawer :first_id="currentRow.school_id" :second_id="currentRow.id" type="school" :uid="drawerUid"/>
         </ElCol>
       </ElRow>
     </el-drawer>
@@ -124,7 +124,6 @@
   // 选中行
   const selectedRows = ref<SchoolContactItem[]>([])
 
-
   // 搜索表单
   const searchForm = ref({
     name: '',
@@ -272,6 +271,8 @@
     })
   }
 
+  const drawerUid = ref(0)
+
   /**
    * 处理弹窗提交事件
    */
@@ -279,7 +280,7 @@
     try {
       dialogVisible.value = false
       followDialogVisible.value = false
-      currentUserData.value = {}
+      drawerUid.value++
       // 延迟更新 不然数据可能没更新
       setTimeout(() => {
         refreshData()

+ 6 - 1
web/src/views/system/user-center/index.vue

@@ -13,7 +13,12 @@
               :headers="{Authorization: useUserStore().accessToken}"
               :on-success="handleSuccess"
           >
-            <img class="avatar" :src="userInfo.avatar || '@imgs/user/avatar.webp'" />
+            <img class="avatar" :src="userInfo.avatar || '@imgs/user/avatar.webp'" title="点击上传"/>
+            <template #tip>
+              <div class="el-upload__tip">
+                点击头像上传
+              </div>
+            </template>
           </el-upload>
           <h2 class="name">{{ userInfo.username }}</h2>
 <!--          <p class="des">逐趣CRM 是一款漂亮的后台管理系统模版.</p>-->