Просмотр исходного кода

feat:校园信息列表和编辑

lizhi 3 месяцев назад
Родитель
Сommit
774dbbc69c
43 измененных файлов с 7899 добавлено и 331 удалено
  1. 1 2
      protected/components/Controller.php
  2. 11 7
      protected/components/DB.php
  3. 382 0
      protected/controllers/CommonController.php
  4. 163 0
      protected/controllers/SchoolController.php
  5. 0 37
      protected/controllers/UseradminController.php
  6. 63 27
      protected/include/Helper.php
  7. 8 0
      protected/include/Lewaimai/LewaimaiAdminPingtaiAuth.php
  8. 0 112
      protected/models/Role.php
  9. 8 2
      script/upgrade/1.0.0.sql
  10. 4 5
      web/src/api/roleApi.ts
  11. 56 0
      web/src/api/schoolApi.ts
  12. 4 4
      web/src/api/usersApi.ts
  13. 1 1
      web/src/components/core/forms/art-button-more/index.vue
  14. 8 2
      web/src/components/core/layouts/art-header-bar/index.vue
  15. 2 1
      web/src/components/core/layouts/art-page-content/index.vue
  16. 12 4
      web/src/components/core/views/login/LoginLeftView.vue
  17. 1 1
      web/src/composables/useHeaderBar.ts
  18. 1 1
      web/src/directives/auth.ts
  19. 10 1
      web/src/locales/langs/en.json
  20. 10 1
      web/src/locales/langs/zh.json
  21. 3 4
      web/src/router/guards/beforeEach.ts
  22. 78 1
      web/src/router/routes/asyncRoutes.ts
  23. 5 1
      web/src/router/routesAlias.ts
  24. 5362 0
      web/src/router/utils/city.ts
  25. 2 2
      web/src/store/modules/user.ts
  26. 1 1
      web/src/types/router/index.ts
  27. 50 0
      web/src/typings/api.d.ts
  28. 25 0
      web/src/typings/form.d.ts
  29. 15 19
      web/src/utils/http/index.ts
  30. 67 43
      web/src/views/auth/forget-password/index.vue
  31. 6 7
      web/src/views/auth/login/index.vue
  32. 1 2
      web/src/views/dashboard/console/index.vue
  33. 269 0
      web/src/views/school/comment/index.vue
  34. 116 0
      web/src/views/school/detail/index.vue
  35. 297 0
      web/src/views/school/edit.vue
  36. 358 0
      web/src/views/school/list/index.vue
  37. 92 0
      web/src/views/school/list/user-search.vue
  38. 359 0
      web/src/views/school/publish/index.vue
  39. 1 3
      web/src/views/system/menu/index.vue
  40. 17 15
      web/src/views/system/role/index.vue
  41. 18 11
      web/src/views/system/user/index.vue
  42. 6 11
      web/src/views/system/user/modules/user-dialog.vue
  43. 6 3
      web/src/views/system/user/modules/user-search.vue

+ 1 - 2
protected/components/Controller.php

@@ -66,8 +66,7 @@ class Controller extends CController
 		$controller = Yii::app()->controller->id;
 		$action = $this->getAction()->getId();
         $path = strtolower($controller . '/'. $action);
-
-		if($controller != 'common'
+		if( !in_array($controller, ['common', 'site'])
             &&!in_array($path, LewaimaiAdminPingtaiAuth::$noLoginRouters)
             && Yii::app()->user->isGuest
         ){

+ 11 - 7
protected/components/DB.php

@@ -156,9 +156,7 @@ class DB
         $id = $filters['id'];
         unset($filters['id']);
 
-        $ret = $db->update($tableName, $filters, "id=:id", array(':id' => $id));
-
-        return true;
+        return $db->update($tableName, $filters, "id=:id", array(':id' => $id));
     }
 
     public static function updateById($tableName, $info, $id)
@@ -168,9 +166,16 @@ class DB
             return false;
         }
         $db = self::getDbCommand();
-        $db->update(self::formTableName($tableName), $info, "id=:id", [':id' => $id]);
+        return $db->update(self::formTableName($tableName), $info, "id=:id", [':id' => $id]);
+    }
 
-        return true;
+    public static function getInfoById($tableName, $id, $fields = '*')
+    {
+        $id = intval($id);
+        if ($id <= 0) {
+            return [];
+        }
+        return self::getInfoWithCriteria($tableName, DbCriteria::simpleCompare(['id' => $id])->setSelect($fields));
     }
 
     public static function deleteById($tableName, $id)
@@ -180,8 +185,7 @@ class DB
             return false;
         }
         $db = self::getDbCommand();
-        $db->delete(self::formTableName($tableName), "id=:id", [':id' => $id]);
-        return true;
+        return $db->delete(self::formTableName($tableName), "id=:id", [':id' => $id]);
     }
 
     public static function formTableName($tableName)

+ 382 - 0
protected/controllers/CommonController.php

@@ -21,6 +21,388 @@ class CommonController extends Controller
         Helper::dealCommonResult(SMS::getInstance()->send($phone, '2094847', [$code]));
     }
 
+    public function actionJson()
+    {
+        $newData = [];
+        $data = file_get_contents(PROJECT_PATH . '/runtime/city.json');
+        foreach (json_decode($data, true) as $province) {
+            $newData[] = ['label' => $province['province'], 'value' => $province['province'], 'children' => array_map(function($city) {
+                return ['label' => $city['city'], 'value' => $city['city'], 'children' => array_map(function($area) {
+                    return ['label' => $area['area'], 'value' => $area['area']];
+                }, $city['areas'])];
+            }, $province['citys'])];
+        }
+        echo json_encode($newData, JSON_UNESCAPED_UNICODE);
+    }
+
+    public function actionEdit()
+    {
+        $table = Helper::getGetString('t1');
+        if (!$table) {
+            Helper::error('参数错误');
+        }
+        $table = DB::formTableName($table);
+        $sql = "show full columns from {$table}";
+        $taleInfo = \Yii::app()->db->createCommand($sql)->queryAll();
+        if (!$taleInfo) {
+            Helper::error('该表不存在');
+        }
+        $excludeField = ['create_date', 'update_date', 'is_delete', 'id', 'city', 'area', 'province'];
+        $this->getEditHtml($taleInfo, $excludeField);
+    }
+
+    public function getEditHtml(array $tableFieldArr, array $excludeField = []): void
+    {
+        $data = [];
+        $notNullField = [];
+        $allowEmptyField = [];
+        echo '$data = [<br/>';
+        foreach ($tableFieldArr as $column) {
+            if (in_array($column['Field'], $excludeField)) {
+                continue;
+            }
+            $name = $column['Field'];
+            $tmpl = "'{$name}' => Helper::{{method}}('{$name}'),<br/>";
+            if (Helper::hasAnyString($column['Type'], ['decimal', 'double', 'float'])) {
+                $method = 'getPostFloat';
+            } elseif (Helper::hasAnyString($column['Type'], ['int'])) {
+                $method = 'getPostInt';
+            } elseif (Helper::hasAnyString($column['Type'], ['datetime'])) {
+                $method = 'getPostDatetime';
+            } elseif (Helper::hasAnyString($column['Type'], ['date'])) {
+                $method = 'getPostDate';
+            } else {
+                $method = 'getPostString';
+            }
+            echo str_replace('{{method}}', $method, $tmpl);
+            if ($column['Null'] == 'NO') {
+                $notNullField[] = $name;
+            }
+            if ($column['Default'] != null || $method == 'getPostInt') {
+                $allowEmptyField[] = $name;
+            }
+        }
+        echo '];<br/><br/>';
+        echo '$notNullField = ' . json_encode($notNullField) . ';<br/>';
+        echo '$allowEmptyField = ' . json_encode($allowEmptyField) . ';<br/>';
+    }
+
+    public function actionsqlToTs()
+    {
+        $table = Helper::getGetString('t1');
+        if (!$table) {
+            Helper::error('参数错误');
+        }
+        $table = strtolower($table);
+        $table1 = DB::formTableName($table);
+        $sql = "show full columns from {$table1}";
+        $taleInfo = \Yii::app()->db->createCommand($sql)->queryAll();
+        if (!$taleInfo) {
+            Helper::error('该表不存在');
+        }
+        $excludeField = ['create_date', 'update_date', 'is_delete'];
+        echo $this->getTsInterFace($table, $taleInfo, $excludeField);
+        // echo $this->getTableHtml($taleInfo, $excludeField);
+        $editTmpl = file_get_contents(PROJECT_PATH . '/protected/runtime/edit.tmpl');
+        $fromExcludeField = ['create_date', 'update_date', 'is_delete', 'id', 'city', 'area', 'province'];
+        echo $this->getTsInterFace($table, $taleInfo, $fromExcludeField);
+        $formDefault = $this->getFormDefault($taleInfo, $fromExcludeField);
+        list($template, $rule) = $this->getFormAndRule($table, $taleInfo, $fromExcludeField);
+        $editTmpl = str_replace(
+            ['{{template}}', '{{table}}', '{{formDefault}}', '{{formRule}}', '{{ucTable}}'],
+            [$template, $table, $formDefault, $rule, ucfirst($table)],
+            $editTmpl
+        );
+        $filePath = PROJECT_PATH . "/web/src/views/{$table}/edit.vue";
+        file_put_contents($filePath, $editTmpl);
+        // 格式化代码
+        echo "<h4>命令后执行下面代码</h4>";
+        echo "<p style='color: red;'>npx prettier --write $filePath</p>";
+    }
+
+    /**
+     * 生成ts接口
+     * @param  string  $table
+     * @param  array  $tableFieldArr
+     * @param array $excludeField 排除的字段
+     * @return string
+     */
+    private function getTsInterFace(string $table, array $tableFieldArr, array $excludeField = []): string
+    {
+        $content = '';
+        foreach ($tableFieldArr as $column) {
+            if (in_array($column['Field'], $excludeField)) {
+                continue;
+            }
+            $type = $this->getTypeByColumn($column);
+            $temp = $column['Field'] . ": {$type}";
+            $comment = $column['Comment'] ? " // {$column['Comment']}" : '';
+            $content.= $temp . " {$comment}, <br/>";
+        }
+        $template = <<<typescript
+interface {$table} {<br/>
+$content}<br/><br/>
+typescript;
+
+        return $template;
+    }
+
+    private function getTypeByColumn(array $column) :string
+    {
+        $type = 'string';
+        if (str_contains($column['Field'], 'ids')) {
+            $type = 'number[]';
+        } elseif (Helper::hasAnyString($column['Type'], ['int', 'decimal', 'double', 'float'])) {
+            if (Helper::hasAnyString($column['Field'], ['is_', 'can_', 'has_', 'status', '_status', 'type'])) {
+                $type = '0|1';
+            } else {
+                $type = 'number';
+            }
+        }
+        return $type;
+    }
+
+    /**
+     * 生成form默认值
+     * @param  array  $tableFieldArr
+     * @param array $excludeField 排除的字段
+     * @return string
+     */
+    private function getFormDefault(array $tableFieldArr, array $excludeField = []): string
+    {
+        $content = '';
+        foreach ($tableFieldArr as $column) {
+            if (in_array($column['Field'], $excludeField)) {
+                continue;
+            }
+            $type = $this->getTypeByColumn($column);
+            if (in_array($type, ['number', '0|1'])) {
+                $default = $column['Default'] ? :  0;
+                $content.= $column['Field'] . ": {$default},";
+            } else {
+                $default = $column['Default'] ? :  '';
+                $content.= $column['Field'] . ": '{$default}',";
+            }
+        }
+        return $content;
+    }
+
+    /**
+     * 生成form验证规则 前后端需要保持一致
+     * @param  string  $table
+     * @param  array  $tableFieldArr
+     * @param array $excludeField 排除的字段
+     * @return string
+     */
+    private function getFormAndRule(string $table, array $tableFieldArr, array $excludeField = []): array
+    {
+        $html = '';
+        $ruleHtml = '';
+        foreach ($tableFieldArr as $column) {
+            if (in_array($column['Field'], $excludeField)) {
+                continue;
+            }
+            $comment = explode(' ', $column['Comment'])[0];
+            $rule = [];
+            if ($column['Null'] == 'NO') {
+                $rule[] = "{ required: true, message: '请输入{$comment}', trigger: 'blur' }";
+            }
+            $type = $this->getTypeByColumn($column);
+            if ($type == 'string') {
+                // 图片处理
+                if (Helper::hasAnyString($column['Field'], ['img', 'image'])) {
+                    // 单张图片
+                } elseif (Helper::hasAnyString($column['Field'], ['images', 'imgs'])) {
+                    // 多张图片
+                } elseif (Helper::hasAnyString($column['Field'], ['_id'])) {
+                    // select
+                } elseif (Helper::hasAnyString($column['Field'], ['ids'])) {
+                    // 多选框
+                } else {
+                    // input框
+                    $inputType = 'text';
+                    $max = $this->getMaxLength($column);
+                    if ($max) {
+                        $min = Helper::hasAnyString($column['Field'], ['']) ? 0 : 0;
+                        if ($min) {
+                            $rule[] = "{ min: {$min}, max: {$max}, message: '长度在{$min}到{$max}个字符', trigger: 'blur' }";
+                        } else {
+                            $rule[] = "{ max: {$max}, message: '长度最多{$max}个字符', trigger: 'blur' }";
+                        }
+                        if ($max > 64) {
+                            $inputType = 'textarea';
+                        }
+                    }
+                    $html.= $this->getInputHtml($comment, $column['Field'], $inputType, $max);
+                }
+            } elseif ($type == 'number') {
+                $html.= $this->getInputHtml($comment, $column['Field'], 'number');
+            } elseif ($type == '0|1') {
+                $rule = '';
+                $html.= $this->getSelectHtml($comment, $column['Field'], $this->getSelectData($column, $type));
+            }
+            if ($rule) {
+                $rule = implode(',', $rule);
+                $ruleHtml.= $column['Field'] . ": [{$rule}],";
+            }
+        }
+        $content =  <<<typescript
+<template>
+    <ElForm ref="formRef" :model="formData" :rules="rules" label-width="auto">
+    <el-row :gutter="20">
+      {$html}
+      <el-col :span="24">
+        <ElFormItem label=" " prop="">
+          <ElButton type="primary" @click="handleSubmit">提交</ElButton>
+        </ElFormItem>
+      </el-col>
+      </el-row>
+    </ElForm>
+</template>
+typescript;
+
+        return [$content, $ruleHtml];
+    }
+
+    public function getInputHtml(string $name, string $field, string $type, int $maxLength = 0): string
+    {
+        $maxLengthHtml = '';
+        if ($type != 'number' && $maxLength > 0) {
+            $maxLengthHtml = "maxlength='{$maxLength}'";
+        }
+        $elHtml = $type != 'textarea' ? ':xs="24" :lg="8" :sm="12"' : ':span=24';
+        return <<<input
+
+<el-col {$elHtml}>
+<ElFormItem label="{$name}" prop="{$field}">
+    <ElInput v-model="formData.{$field}" $maxLengthHtml type="{$type}"/>
+</ElFormItem>
+</el-col>
+
+input;
+    }
+
+    public function getSelectHtml(string $name, string $field, array $selectData, bool $multi = false): string
+    {
+        if (!$selectData) {
+            return [];
+        }
+        $data = '';
+        foreach ($selectData as $key => $item) {
+            if (is_numeric($key)) {
+                $data.= "{label: '{$item}', value: {$key}}, ";
+            } else {
+                $data.= "{label: '{$item}', value: '{$key}'},";
+            }
+        }
+        return <<<select
+
+<el-col :xs="24" :lg="8" :sm="12">
+<ElFormItem label="{$name}" prop="{$field}">
+  <el-select v-model="formData.{$field}" placeholder="请选择">
+    <el-option v-for="item in [{$data}]" :key="item.value" :value="item.value" :label="item.label"/>
+  </el-select>
+</ElFormItem>
+</el-col>
+
+select;
+    }
+
+
+    /**
+     * 生成table字段配置
+     * @param  array  $tableFieldArr
+     * @param array $excludeField 排除的字段
+     * @return string
+     */
+    private function getTableHtml(array $tableFieldArr, $excludeField = []): string
+    {
+        $tableContent = '';
+        foreach ($tableFieldArr as $column) {
+            if (in_array($column['Field'], $excludeField)) {
+                continue;
+            }
+            $commentArr = explode(' ', $column['Comment']);
+            $JsTableArr = ['prop' => $column['Field'], 'label' => $commentArr[0]];
+
+            // 长字段列表需要添加showOverflowTooltip
+            if ($this->getMaxLength($column) || $this->isTextField($column)) {
+                $JsTableArr['showOverflowTooltip'] = true;
+            }
+
+            if (str_contains($column['Field'], 'ids')) {
+                $JsTableArr = []; // TODO:这种一般是tag集合
+            } elseif (Helper::hasAnyString($column['Field'], ['is', 'can', 'has', 'status'])) {
+                if (count($commentArr) == 3) {
+                    $arr1 = explode('-', $commentArr[1]);
+                    $arr2 = explode('-', $commentArr[2]);
+                    $tempArr[intval($arr1[0])] = $arr1[1];
+                    $tempArr[intval($arr2[0])] = $arr2[1];
+                } else {
+                    $tempArr = [0 => '否', 1 => '是'];
+                }
+                $JsTableArr['formatter'] = <<<HTML
+    (row) => {<br/>
+            return h(ElTag, { type: row.{$column['Field']} ? 'success' : 'danger' }, () => row.{$column['Field']} ? '{$tempArr[1]}' : '{$tempArr[0]}')<br/>
+        }
+HTML;
+            }
+            $tableContent.= $this->getHtmlWithJsTableArr($JsTableArr);
+        }
+        $template = <<<typescript
+{<br/>
+$tableContent}<br/><br/>
+typescript;
+        return $template;
+    }
+
+    private function getMaxLength(array $column):int
+    {
+        preg_match('/(\d+)/', $column['Type'], $match);
+        return $match[0] ?? 0;
+    }
+
+    private function isNumberFiled(?string $type = null)
+    {
+        return $type == 'number' || str_contains($type, '|');
+    }
+
+    private function isTextField(array $column):int
+    {
+        return Helper::hasAnyString($column['Type'], ['text']);
+    }
+
+    public function getSelectData(array $column, ?string $type = null):array
+    {
+        if (!$type) {
+            $type = $this->getTypeByColumn($column);
+        }
+        if (!$this->isNumberFiled($type)) {
+            return [];
+        }
+        $arr = [];
+        preg_match_all('/(\d+\-\S+)/', $column['Comment'], $match);
+        if ($match) {
+            foreach ($match[0] as $matchItem) {
+                list($key, $value) = explode('-', $matchItem);
+                $arr[$key] = $value;
+            }
+        }
+        return $arr;
+    }
+
+    private function getHtmlWithJsTableArr(array $arr): string
+    {
+        if (!$arr) {
+            return '';
+        }
+        $str = '{';
+        foreach ($arr as $k => $v) {
+            $str.= " {$k}:{$v},";
+        }
+        return trim($str, ',') . ' },<br/>';
+    }
+
     public function actionSetPassword()
     {
         $phone = Helper::getPostString('phone');

+ 163 - 0
protected/controllers/SchoolController.php

@@ -0,0 +1,163 @@
+<?php
+
+class SchoolController extends Controller
+{
+    public function actionInfo()
+    {
+        $id = Helper::getPostInt('id');
+        if ($id <= 0) {
+            Helper::error('参数错误');
+        }
+        $data = DB::getInfoById('school', $id);
+        if (!$data) {
+            Helper::error('数据不存在');
+        }
+        $data['distinct'] = [
+            $data['province'],
+            $data['city'],
+            $data['area'],
+        ];
+        Helper::ok($data);
+    }
+
+    public function actionList()
+    {
+        $filter = [];
+        $address = Helper::getArrParam($_POST, 'address', Helper::PARAM_KEY_TYPE['array_string']);
+        $filter['province'] = $address[0]?? null;
+        $filter['city'] = $address[1]?? null;
+        $filter['area'] = $address[2]?? null;
+        if ($name = Helper::getPostString('name')) {
+            $filter['name'] = '%' . $name;
+        }
+        $is_cooperate = Helper::getPostInt('is_cooperate');
+        if ($is_cooperate != -1) {
+             $filter['is_cooperate'] = $is_cooperate;
+        }
+        $cri = DbCriteria::simpleCompareWithPage($filter);
+        $data = DB::getListWithCriteria('school', $cri);
+        if (!empty($data['records'])) {
+            $data['records'] = array_map(function ($item) {
+                return $item;
+            }, $data['records']);
+        }
+        Helper::ok($data);
+    }
+
+    /**
+     * 下拉列表获取
+     * @return void
+     */
+    public function actionGetSelectList()
+    {
+        $cri = DbCriteria::simpleCompare([])->setSelect('id, name');
+        $data = DB::getListWithCriteria('school', $cri);
+        Helper::ok($data['records']??[]);
+    }
+
+    public function actionDelete()
+    {
+        $id = Helper::getPostInt('id');
+        if ($id < 1) {
+            Helper::error('参数错误');
+        }
+        DB::deleteById('school', $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 = [
+            'name' => Helper::getPostString('name'),
+            'address' => Helper::getPostString('address'),
+            'person_num' => Helper::getPostString('person_num'),
+            'bind_user_id' => Helper::getPostInt('bind_user_id'),
+            'is_eleme_in_school' => Helper::getPostInt('is_eleme_in_school'),
+            'is_eleme_out_school' => Helper::getPostInt('is_eleme_out_school'),
+            'is_meituan_in_school' => Helper::getPostInt('is_meituan_in_school'),
+            'is_meituan_out_school' => Helper::getPostInt('is_meituan_out_school'),
+            'can_go_upstairs' => Helper::getPostInt('can_go_upstairs'),
+            'is_cooperate' => Helper::getPostInt('is_cooperate'),
+            'can_ride' => Helper::getPostInt('can_ride'),
+            'dormitory_distribution' => Helper::getPostString('dormitory_distribution'),
+            'qucan_station_distribution' => Helper::getPostString('qucan_station_distribution'),
+            'out_business_description' => Helper::getPostString('out_business_description'),
+            'memo' => Helper::getPostString('memo'),
+        ];
+
+        $notNullField = ["name","address","person_num","bind_user_id","is_eleme_in_school","is_eleme_out_school"
+                         ,"is_meituan_in_school","is_meituan_out_school","can_go_upstairs","is_cooperate","can_ride"];
+        $allowEmptyField = ["bind_user_id","is_eleme_in_school","is_eleme_out_school","is_meituan_in_school"
+                            ,"is_meituan_out_school","can_go_upstairs","is_cooperate","can_ride"];
+
+        // 空字段检测
+        if (!Helper::checkEmptyKey($data, $notNullField, $allowEmptyField)) {
+            Helper::error('参数错误');
+        }
+
+        // 处理地区
+        $district = Helper::getArrParam($_POST, 'distinct', Helper::PARAM_KEY_TYPE['array_string']);
+        $district = array_filter($district);
+        if (count($district) != 3) {
+            Helper::error('地区参数错误');
+        }
+        $data['province'] = $district[0];
+        $data['city'] = $district[1];
+        $data['area'] = $district[2];
+
+        $name = $data['name'];
+        // 检测名称重复
+        $cri = DbCriteria::simpleCompare(['name' => $name])->setSelect('id');
+        if ($id > 0) {
+            $cri->addCondition('id!=' . $id);
+        }
+        if ($fid = DB::getScalerWithCriteria('school', $cri)) {
+            Helper::error('学校名称已存在 ' . $fid);
+        }
+
+        if ($id) {
+            DB::updateById('school', $data, $id);
+        } else {
+            DB::addData('school', $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_eleme_in_school', 'person_num'])) {
+            Helper::error('参数错误2');
+        }
+        if ($attr == 'is_eleme_in_school' && !in_array($value, [1, 0])) {
+            Helper::error('参数错误3');
+        }
+        if (DB::updateById('school', [$attr => $value], $id) === false) {
+            Helper::error('更新失败');
+        }
+        Helper::ok();
+    }
+
+    private function _edit()
+    {
+    }
+}

+ 0 - 37
protected/controllers/UseradminController.php

@@ -182,42 +182,5 @@ class UseradminController extends Controller
             DB::addData('role', ['name' => $name, 'descr' => $descr]);
         }
         Helper::ok();
-    }
-    
-	public function actionCheckpwd(){
-		$pass = $_POST['pass'];
-		$new_passwd = trim($_POST['new_passwd']);
-		$confir_passwd = trim($_POST['confir_passwd']);
-		
-		if( !$pass ) {
-			$arr = array('status'=>'failed','code'=>0);
-		}
-		
-		if( !$new_passwd ) {
-			$arr = array('status'=>'failed','code'=>1);
-		}
-		
-		if( !$confir_passwd ) {
-			$arr = array('status'=>'failed','code'=>2);
-		}
-		
-		if( $new_passwd != $confir_passwd ) {
-			$arr = array('status'=>'failed','code'=>4);
-			$this->response($arr);
-		}
-		
-		$id = Yii::app()->user->_id;
-		$userAdminModel = Useradmin::model()->findByPk($id);
-		if( md5($pass) != $userAdminModel->password ) {
-			$arr = array('status'=>'failed','code'=>5);
-			$this->response($arr);
-		}
-		
-		$userAdminModel->password = md5($new_passwd);
-		$userAdminModel->verifypassword = md5($new_passwd);
-		if($userAdminModel->save()) {
-			Yii::app()->user->logout();
-			$this->response(array('status'=>'success'));
-		}
 	}
 }

+ 63 - 27
protected/include/Helper.php

@@ -4,39 +4,35 @@ use lwmf\base\Logger;
 
 class Helper
 {
-    /**
-     * 将数组转为cmodel对象
-     *
-     * @param array $arr
-     * @return object
-     */
-    public static function arrayToModel($arr)
-    {
-        $arr = (array)$arr;
+    const PARAM_KEY_TYPE = [
+        'string'       => 'string', // 字符串
+        'int'          => 'int', // 数字
+        'float'        => 'float', // float数字
+        'date'         => 'date', // 日期
+        'datetime'     => 'datetime', // 日期时间
+        'array_string' => 'array_string', // 包含字符串的数组
+        'array_float'  => 'array_float', // 包含float的数组
+        'array_int'    => 'array_int', // 包含int的数组
+    ];
 
-        $model = new \DynamicModel();
-        $model->setAttributes($arr);
-        return $model;
-    }
-    
     /**
-     * 将数据库中获取的对象数组转为数组
-     * @param array $arr
-     * @return array
+     * 检查字符串中是否含有任何一个子串
+     * @param $str
+     * @param  array  $arr
+     * @param  bool  $case  是否区分大小写 0-否
+     * @return bool
      */
-    public static function objectToArray(array $arr, $column = '')
+    public static function hasAnyString($str, array $arr, bool $case = false): bool
     {
-        $ret = [];
-        if (!empty($arr)) {
-            foreach ($arr as $item) {
-                if ($column) {
-                    $ret[$item->$column] = $item->attributes;
-                } else {
-                    $ret[] = $item->attributes;
-                }
+        foreach ($arr as $v) {
+            if (!$case && stripos($str, $v) !== false) {
+                return true;
+            } elseif ($case && str_contains($str, $v)) {
+                return true;
             }
         }
-        return $ret;
+
+        return false;
     }
 
     /**
@@ -116,11 +112,51 @@ class Helper
         return $text;
     }
 
+    /**
+     * 检查数组是否指定键都存在 true-都在
+     * - checkEmptyKey(['num' => 1], ['num']) => true
+     * - checkEmptyKey(['num' => 1], ['id']) => false
+     * - checkEmptyKey(['num' => 0], ['num']) => false
+     * - checkEmptyKey(['num' => 0], ['num'], ['num') => true
+     *
+     * @param array $data
+     * @param $keys
+     * @param array $allowEmpty 允许为空的字段
+     *
+     * @return bool true-通过检查
+     * @author lizhi <1458705589@qq.com>
+     * @date   2021/6/18
+     */
+    public static function checkEmptyKey($data, $keys, array $allowEmpty = [])
+    {
+        if (is_array($keys)) {
+            foreach ($keys as $item) {
+                if (!isset($data[$item]) || (!in_array($item, $allowEmpty) && empty($data[$item]))) {
+                    return false;
+                }
+            }
+        } elseif (!isset($data[$keys]) || (!in_array($keys, $allowEmpty) && empty($data[$keys]))) {
+            return false;
+        }
+
+        return true;
+    }
+
     public static function getPostString($key, $default = null)
     {
         return self::getArrParam($_POST, $key, 'string', $default);
     }
 
+    public static function getPostFloat($key, $default = null)
+    {
+        return self::getArrParam($_POST, $key, 'float', $default);
+    }
+
+    public static function getGetFloat($key, $default = null)
+    {
+        return self::getArrParam($_GET, $key, 'float', $default);
+    }
+
     public static function isPhone(?string $phone)
     {
         return $phone && preg_match('/^1[3456789]\d{9}$/', $phone);

+ 8 - 0
protected/include/Lewaimai/LewaimaiAdminPingtaiAuth.php

@@ -75,6 +75,14 @@ class LewaimaiAdminPingtaiAuth
             'useradmin/edituser' => 110101, // 编辑用户
             'useradmin/deleteuser' => 110102, // 删除用户
 
+            // ===================   学校相关  =======================
+            'school/list' => 1201,
+            'school/info' => 1201,
+            'school/add' => 120101,
+            'school/edit' => 120102,
+            'school/updateattr' => 120102,
+            'school/delete' => 120103,
+
         ];
 
         return !empty($pageAuth[$page]) && self::getAuth($pageAuth[$page]);

+ 0 - 112
protected/models/Role.php

@@ -1,112 +0,0 @@
-<?php
-
-/**
- * This is the model class for table "{{role}}".
- *
- * The followings are the available columns in table '{{role}}':
- * @property integer $id
- * @property string $name
- * @property string $auth_ids
- * @property integer $status
- * @property string $descr
- * @property string $create_date
- * @property string $update_date
- */
-class Role extends CActiveRecord
-{
-	/**
-	 * @return string the associated database table name
-	 */
-	public function tableName()
-	{
-		return '{{role}}';
-	}
-
-	/**
-	 * @return array validation rules for model attributes.
-	 */
-	public function rules()
-	{
-		// NOTE: you should only define rules for those attributes that
-		// will receive user inputs.
-		return array(
-			array('name, create_date, update_date', 'required'),
-			array('status', 'numerical', 'integerOnly'=>true),
-			array('name', 'length', 'max'=>20),
-			array('descr', 'length', 'max'=>255),
-			array('auth_ids', 'safe'),
-			// The following rule is used by search().
-			// @todo Please remove those attributes that should not be searched.
-			array('id, name, auth_ids, status, descr, create_date, update_date', 'safe', 'on'=>'search'),
-		);
-	}
-
-	/**
-	 * @return array relational rules.
-	 */
-	public function relations()
-	{
-		// NOTE: you may need to adjust the relation name and the related
-		// class name for the relations automatically generated below.
-		return array(
-		);
-	}
-
-	/**
-	 * @return array customized attribute labels (name=>label)
-	 */
-	public function attributeLabels()
-	{
-		return array(
-			'id' => 'ID',
-			'name' => '角色名',
-			'auth_ids' => '权限ID(用逗号隔开)',
-			'status' => '是否可用,默认1表示可用,0表示已经删除',
-			'descr' => '简介',
-			'create_date' => '创建时间',
-			'update_date' => '更新时间',
-		);
-	}
-
-	/**
-	 * Retrieves a list of models based on the current search/filter conditions.
-	 *
-	 * Typical usecase:
-	 * - Initialize the model fields with values from filter form.
-	 * - Execute this method to get CActiveDataProvider instance which will filter
-	 * models according to data in model fields.
-	 * - Pass data provider to CGridView, CListView or any similar widget.
-	 *
-	 * @return CActiveDataProvider the data provider that can return the models
-	 * based on the search/filter conditions.
-	 */
-	public function search()
-	{
-		// @todo Please modify the following code to remove attributes that should not be searched.
-
-		$criteria=new CDbCriteria;
-
-		$criteria->compare('id',$this->id);
-		$criteria->compare('name',$this->name,true);
-		$criteria->compare('auth_ids',$this->auth_ids,true);
-		$criteria->compare('status',$this->status);
-		$criteria->compare('descr',$this->descr,true);
-		$criteria->compare('create_date',$this->create_date,true);
-		$criteria->compare('update_date',$this->update_date,true);
-
-		return new CActiveDataProvider($this, array(
-			'criteria'=>$criteria,
-		));
-	}
-
-	/**
-	 * Returns the static model of the specified AR class.
-	 * Please note that you should have this exact method in all your CActiveRecord descendants!
-	 * @param string $className active record class name.
-	 * @return Role the static model class
-	 */
-	public static function model($className=__CLASS__)
-	{
-		return parent::model($className);
-	}
-}

+ 8 - 2
script/upgrade/1.0.0.sql

@@ -42,7 +42,8 @@ CREATE TABLE `wx_school` (
   `is_meituan_in_school` TINYINT(4) NOT NULL default 0 COMMENT '是否有美团校内站 0-无 1-有',
   `is_meituan_out_school` TINYINT(4) NOT NULL default 0 COMMENT '是否有美团校外站 0-无 1-有',
   `can_go_upstairs` TINYINT(4) NOT NULL default 0 COMMENT '是否能上楼 0-能 1-不能',
-  `is_cooperate` TINYINT(4) NOT NULL default 0 COMMENT '是否合作 0-无 1-有',
+  `is_cooperate` TINYINT(4) NOT NULL default 0 COMMENT '是否合作 0-否 1-是',
+  `is_del` TINYINT(4) NOT NULL default 0 COMMENT '是否删除 0-否 1-是',
   `can_ride` TINYINT(4) NOT NULL default 0 COMMENT '是否允许骑电动车 0-能 1-不能',
   `dormitory_distribution` VARCHAR(1000) default '' COMMENT '宿舍分布情况',
   `qucan_station_distribution` VARCHAR(1000) default '' COMMENT '校门口取餐点离宿舍情况',
@@ -61,6 +62,7 @@ CREATE TABLE `wx_school_contact` (
   `weixin` VARCHAR(20) NOT NULL default '' COMMENT '微信号',
   `position` VARCHAR(20) NOT NULL default '' 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`),
@@ -89,6 +91,7 @@ CREATE TABLE `wx_canteen` (
   `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`),
@@ -104,6 +107,7 @@ CREATE TABLE `wx_canteen_contact` (
   `weixin` VARCHAR(20) NOT NULL default '' COMMENT '微信号',
   `position` VARCHAR(20) NOT NULL default '' 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`),
@@ -135,12 +139,13 @@ CREATE TABLE `wx_company` (
   `address` VARCHAR(255) NOT NULL COMMENT '详细地址',
   `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-是',
   `create_date` datetime NOT NULL DEFAULT now() COMMENT '创建时间',
   `update_date` datetime  NOT NULL DEFAULT now() COMMENT '更新时间',
   PRIMARY KEY (`id`)
 ) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci comment='餐饮公司表';
 
-CREATE TABLE `wx_companycontact` (
+CREATE TABLE `wx_company_contact` (
   `id` INT(11) NOT NULL AUTO_INCREMENT,
   `name` VARCHAR(20) NOT NULL COMMENT '名称',
   `company_id` int(11) NOT NULL default 0 COMMENT '餐饮公司ID',
@@ -149,6 +154,7 @@ CREATE TABLE `wx_companycontact` (
   `position` VARCHAR(20) NOT NULL default '' COMMENT '职位',
   `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-是',
   `create_date` datetime NOT NULL DEFAULT now() COMMENT '创建时间',
   `update_date` datetime  NOT NULL DEFAULT now() COMMENT '更新时间',
   PRIMARY KEY (`id`),

+ 4 - 5
web/src/api/roleApi.ts

@@ -1,7 +1,6 @@
 import request from '@/utils/http'
 
 export class roleService {
-
   // 角色列表
   static roleList(params?: Api.Common.PaginatingSearchParams) {
     return request.post<Api.Role.RoleListResponse>({
@@ -14,17 +13,17 @@ export class roleService {
   // 角色下拉列表
   static roleSelectList() {
     return request.post<Api.Common.SelectInfo[]>({
-      url: 'useradmin/getRoleSelect',
+      url: 'useradmin/getRoleSelect'
       // showErrorMessage: false // 不显示错误消息
     })
   }
 
   // 角色权限设置
-  static saveRoleAuth(id:number, leaf_ids: number[], half_Leaf_ids: number[]) {
-    const params = {id, leaf_ids, half_Leaf_ids}
+  static saveRoleAuth(id: number, leaf_ids: number[], half_Leaf_ids: number[]) {
+    const params = { id, leaf_ids, half_Leaf_ids }
     return request.post<any>({
       url: 'useradmin/saveRoleAuth',
-      params,
+      params
       // showErrorMessage: false // 不显示错误消息
     })
   }

+ 56 - 0
web/src/api/schoolApi.ts

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

+ 4 - 4
web/src/api/usersApi.ts

@@ -12,7 +12,7 @@ export class UserService {
 
   // 发送验证码
   static sendCode(phone: string) {
-    const params = {phone}
+    const params = { phone }
     return request.post<Api.Auth.LoginResponse>({
       url: 'common/sendCode',
       params
@@ -21,7 +21,7 @@ export class UserService {
   }
 
   // 修改密码
-  static findPassword(params:Form.FindPassword) {
+  static findPassword(params: Form.FindPassword) {
     return request.post<Api.Auth.LoginResponse>({
       url: 'common/setPassword',
       params
@@ -31,7 +31,7 @@ export class UserService {
 
   // 获取用户信息
   static getUserInfo() {
-    return request.get<Api.User.UserInfo>({
+    return request.post<Api.User.UserInfo>({
       url: 'useradmin/info'
       // 自定义请求头
       // headers: {
@@ -44,7 +44,7 @@ export class UserService {
   static getUserList(params: Api.Common.PaginatingSearchParams) {
     return request.post<Api.User.UserListData>({
       url: 'useradmin/userlist',
-      params,
+      params
       // showErrorMessage: false // 不显示错误消息
     })
   }

+ 1 - 1
web/src/components/core/forms/art-button-more/index.vue

@@ -21,7 +21,7 @@
 </template>
 
 <script setup lang="ts">
-  import {useUserStore} from "@/store/modules/user";
+  import { useUserStore } from '@/store/modules/user'
 
   defineOptions({ name: 'ArtButtonMore' })
 

+ 8 - 2
web/src/components/core/layouts/art-header-bar/index.vue

@@ -143,10 +143,16 @@
             <template #default>
               <div class="user-menu-box">
                 <div class="user-head">
-                  <img class="cover" :src="userInfo.avatar || '@imgs/user/avatar.webp'" style="float: left" />
+                  <img
+                    class="cover"
+                    :src="userInfo.avatar || '@imgs/user/avatar.webp'"
+                    style="float: left"
+                  />
                   <div class="user-wrap">
                     <span class="name">{{ userInfo.username }}</span>
-                    <span class="email" style="white-space:normal">{{ userInfo.descr || '我就是我,颜色不一样的烟火' }}</span>
+                    <span class="email" style="white-space: normal">{{
+                      userInfo.descr || '我就是我,颜色不一样的烟火'
+                    }}</span>
                   </div>
                 </div>
                 <ul class="user-menu">

+ 2 - 1
web/src/components/core/layouts/art-page-content/index.vue

@@ -20,7 +20,7 @@
           <component
             class="art-page-view"
             :is="Component"
-            :key="route.path"
+            :key="route.fullPath"
             v-if="route.meta.keepAlive"
           />
         </KeepAlive>
@@ -57,6 +57,7 @@
   const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
   const { keepAliveExclude } = storeToRefs(useWorktabStore())
 
+
   const isRefresh = shallowRef(true)
   const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO
   const showTransitionMask = ref(false)

+ 12 - 4
web/src/components/core/views/login/LoginLeftView.vue

@@ -162,11 +162,17 @@
 
           @if $direction == 'up' {
             transform: translateY(30px) rotate($rotation);
-          } @else if $direction == 'down' {
+          }
+
+ @else if $direction == 'down' {
             transform: translateY(-30px) rotate($rotation);
-          } @else if $direction == 'left' {
+          }
+
+ @else if $direction == 'left' {
             transform: translateX(-30px) rotate($rotation);
-          } @else if $direction == 'right' {
+          }
+
+ @else if $direction == 'right' {
             transform: translateX(30px) rotate($rotation);
           }
         }
@@ -176,7 +182,9 @@
 
           @if $direction == 'up' or $direction == 'down' {
             transform: translateY(0) rotate($rotation);
-          } @else {
+          }
+
+ @else {
             transform: translateX(0) rotate($rotation);
           }
         }

+ 1 - 1
web/src/composables/useHeaderBar.ts

@@ -8,7 +8,7 @@ import { storeToRefs } from 'pinia'
 import { useSettingStore } from '@/store/modules/setting'
 import { headerBarConfig } from '@/config/headerBar'
 import { HeaderBarFeatureConfig } from '@/types'
-import {useUserStore} from "@/store/modules/user";
+import { useUserStore } from '@/store/modules/user'
 
 /**
  * 顶部栏功能管理

+ 1 - 1
web/src/directives/auth.ts

@@ -1,6 +1,6 @@
 import { router } from '@/router'
 import { App, Directive, DirectiveBinding } from 'vue'
-import {useUserStore} from "@/store/modules/user";
+import { useUserStore } from '@/store/modules/user'
 
 /**
  * 权限指令(后端控制模式可用)

+ 10 - 1
web/src/locales/langs/en.json

@@ -293,7 +293,16 @@
       "menu3": "Menu 3",
       "menu31": "Menu 3-1",
       "menu32": "Menu 3-2",
-      "menu321": "Menu 3-2-1"
+      "menu321": "Menu 3-2-1",
+      "flowList": "flow list",
+      "flowDetail": "flow detail"
+    },
+    "school": {
+      "list": "school list",
+      "relation": "school relation",
+      "info": "school info",
+      "add": "add school",
+      "edit": "edit school"
     },
     "safeguard": {
       "title": "Safeguard",

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

@@ -285,7 +285,16 @@
       "menu3": "菜单3",
       "menu31": "菜单3-1",
       "menu32": "菜单3-2",
-      "menu321": "菜单3-2-1"
+      "menu321": "菜单3-2-1",
+      "flowList": "跟进记录",
+      "flowDetail": "跟进详情"
+    },
+    "school": {
+      "list": "校园信息",
+      "relation": "校方关系",
+      "info": "校园详情",
+      "add": "新建校园",
+      "edit": "编辑校园"
     },
     "safeguard": {
       "title": "运维管理",

+ 3 - 4
web/src/router/guards/beforeEach.ts

@@ -6,7 +6,6 @@ import { useUserStore } from '@/store/modules/user'
 import { useMenuStore } from '@/store/modules/menu'
 import { setWorktab } from '@/utils/navigation'
 import { setPageTitle, setSystemTheme } from '../utils/utils'
-import { menuService } from '@/api/menuApi'
 import { registerDynamicRoutes } from '../utils/registerRoutes'
 import { AppRouteRecord } from '@/types/router'
 import { RoutesAlias } from '../routesAlias'
@@ -223,7 +222,6 @@ async function processFrontendMenu(router: Router): Promise<void> {
  * 处理后端控制模式的菜单逻辑
  */
 async function processBackendMenu(router: Router): Promise<void> {
-
   // const { menuList } = await menuService.getMenuList()
 
   const menuList = asyncRoutes.map((route) => menuDataToRouter(route))
@@ -233,7 +231,9 @@ async function processBackendMenu(router: Router): Promise<void> {
     throw new Error('获取用户权限失败')
   }
 
-  const filteredMenuList = userStore.isSuperAdmin() ? menuList : filterMenuByAuthIds(menuList, authIds)
+  const filteredMenuList = userStore.isSuperAdmin()
+    ? menuList
+    : filterMenuByAuthIds(menuList, authIds)
   // 添加延时以提升用户体验
   await new Promise((resolve) => setTimeout(resolve, LOADING_DELAY))
 
@@ -321,7 +321,6 @@ const filterMenuByRoles = (menu: AppRouteRecord[], roles: string[]): AppRouteRec
  */
 const filterMenuByAuthIds = (menu: AppRouteRecord[], authIds: number[]): AppRouteRecord[] => {
   return menu.reduce((acc: AppRouteRecord[], item) => {
-
     const hasPermission = !item.hasOwnProperty('id') || !item.id || authIds.includes(item.id)
     if (hasPermission) {
       const filteredItem = { ...item }

+ 78 - 1
web/src/router/routes/asyncRoutes.ts

@@ -36,7 +36,7 @@ export const asyncRoutes: AppRouteRecord[] = [
     component: RoutesAlias.Layout,
     meta: {
       title: 'menus.system.title',
-      icon: '&#xe7b9;',
+      icon: '&#xe7b9;'
     },
     children: [
       {
@@ -90,4 +90,81 @@ export const asyncRoutes: AppRouteRecord[] = [
       }
     ]
   },
+  {
+    id: 12,
+    path: '/school',
+    name: 'SchoolManage',
+    component: RoutesAlias.Layout,
+    meta: {
+      title: 'menus.school.list',
+      icon: '&#xe7b9;'
+    },
+    children: [
+      {
+        id: 1201,
+        path: 'list',
+        name: 'school',
+        component: RoutesAlias.SchoolList,
+        meta: {
+          title: 'menus.school.list',
+          keepAlive: true,
+          authList: [
+            {
+              id: 120101,
+              title: '新增',
+              authMark: 'add'
+            },
+            {
+              id: 120102,
+              title: '编辑',
+              authMark: 'edit'
+            },
+            {
+              id: 110203,
+              title: '删除',
+              authMark: 'delete'
+            }
+          ]
+        }
+      },
+      {
+        path: 'edit',
+        name: 'SchoolEdit',
+        component: RoutesAlias.SchoolEdit,
+        meta: {
+          title: 'menus.school.edit',
+          isHide: true,
+          keepAlive: true,
+          activePath: '/school/list' // 激活菜单路径
+        }
+      }
+      // {
+      //   id: 1202,
+      //   path: 'relation',
+      //   name: 'relation',
+      //   component: RoutesAlias.SchoolRelation,
+      //   meta: {
+      //     title: 'menus.school.relation',
+      //     keepAlive: true,
+      //     authList: [
+      //       {
+      //         id: 110201,
+      //         title: '新增',
+      //         authMark: 'add'
+      //       },
+      //       {
+      //         id: 110202,
+      //         title: '编辑',
+      //         authMark: 'edit'
+      //       },
+      //       {
+      //         id: 110203,
+      //         title: '删除',
+      //         authMark: 'delete'
+      //       }
+      //     ]
+      //   }
+      // }
+    ]
+  }
 ]

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

@@ -54,5 +54,9 @@ export enum RoutesAlias {
   ExamplesTablesBasic = '/examples/tables/basic', // 基础表格示例
   ExamplesTables = '/examples/tables', // 高级表格示例
   ExamplesTablesTree = '/examples/tables/tree', // 左右布局表格示例
-  ExamplesSearchBar = '/examples/forms/search-bar' // 搜索表单示例
+  ExamplesSearchBar = '/examples/forms/search-bar', // 搜索表单示例
+  SchoolList = '/school/list', // 学校列表
+  SchoolDetail = '/school/detail', // 学校详情
+  SchoolEdit = '/school/edit', // 编辑学校
+  SchoolRelation = '/school/relation' // 学校关系
 }

Разница между файлами не показана из-за своего большого размера
+ 5362 - 0
web/src/router/utils/city.ts


+ 2 - 2
web/src/store/modules/user.ts

@@ -102,8 +102,8 @@ export const useUserStore = defineStore(
       }
     }
 
-    const checkAuth = (authId:number) => {
-      return info.value.auth_ids?.includes(authId)
+    const checkAuth = (authId: number) => {
+      return isSuperAdmin() || info.value.auth_ids?.includes(authId)
     }
 
     const isSuperAdmin = () => {

+ 1 - 1
web/src/types/router/index.ts

@@ -44,8 +44,8 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
 
 // 扩展路由记录
 export interface AppRouteRecord extends Omit<RouteRecordRaw, 'meta' | 'children' | 'component'> {
-  id?: number
   /** 权限ID 没有或者0就是不需要权限 */
+  id?: number
   meta: RouteMeta
   children?: AppRouteRecord[]
   component?: string | (() => Promise<any>)

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

@@ -129,4 +129,54 @@ declare namespace Api {
       role_name: string
     }
   }
+
+  namespace School {
+    interface SchoolListData {
+      records: SchoolListItem[]
+      current: number
+      size: number
+      total: number
+    }
+
+    interface SchoolListItem {
+      id: number
+      name: string // 名称,
+      province: string // 省,
+      city: string // 市,
+      area: string // 区,
+      address: string // 详细地址,
+      person_num: string // 在校人数,
+      bind_user_id: number // 负责人,
+      is_eleme_in_school: 0 | 1 // 是否有饿了么校内站 0-无 1-有,
+      is_eleme_out_school: 0 | 1 // 是否有饿了么校外站 0-无 1-有,
+      is_meituan_in_school: 0 | 1 // 是否有美团校内站 0-无 1-有,
+      is_meituan_out_school: 0 | 1 // 是否有美团校外站 0-无 1-有,
+      can_go_upstairs: 0 | 1 // 是否能上楼 0-能 1-不能,
+      is_cooperate: 0 | 1 // 是否合作 0-无 1-有,
+      can_ride: 0 | 1 // 是否允许骑电动车 0-能 1-不能,
+      dormitory_distribution: string // 宿舍分布情况,
+      qucan_station_distribution: string // 校门口取餐点离宿舍情况,
+      out_business_description: string // 校外商圈情况,
+      memo: string // 备注,
+    }
+
+    interface SchoolContactItem {
+      id: number
+      name: string // 名称,
+      school_id: number // 学校ID,
+      phone: string // 手机号,
+      weixin: string // 微信号,
+      position: string // 职位,
+      memo: string // 备注,
+      create_date: string // 创建时间,
+      update_date: string // 更新时间,
+    }
+
+    interface SchoolContactListData {
+      records: SchoolContactItem[]
+      current: number
+      size: number
+      total: number
+    }
+  }
 }

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

@@ -18,4 +18,29 @@ declare namespace Form {
     code: string
     password: string
   }
+
+  interface UpdateAttr {
+    id: number
+    attr: string
+    value: string
+  }
+
+  interface School {
+    name: string // 名称,
+    distinct: string[] // 地区,
+    address: string // 详细地址,
+    person_num: string // 在校人数,
+    bind_user_id: number // 负责人,
+    is_eleme_in_school: 0 | 1 // 是否有饿了么校内站 0-无 1-有,
+    is_eleme_out_school: 0 | 1 // 是否有饿了么校外站 0-无 1-有,
+    is_meituan_in_school: 0 | 1 // 是否有美团校内站 0-无 1-有,
+    is_meituan_out_school: 0 | 1 // 是否有美团校外站 0-无 1-有,
+    can_go_upstairs: 0 | 1 // 是否能上楼 0-能 1-不能,
+    is_cooperate: 0 | 1 // 是否合作 0-无 1-有,
+    can_ride: 0 | 1 // 是否允许骑电动车 0-能 1-不能,
+    dormitory_distribution: string // 宿舍分布情况,
+    qucan_station_distribution: string // 校门口取餐点离宿舍情况,
+    out_business_description: string // 校外商圈情况,
+    memo: string // 备注,
+  }
 }

+ 15 - 19
web/src/utils/http/index.ts

@@ -3,7 +3,7 @@ import { useUserStore } from '@/store/modules/user'
 import { ApiStatus } from './status'
 import { HttpError, handleError, showError } from './error'
 import { $t } from '@/locales'
-import CryptoJS from 'crypto-js';
+import CryptoJS from 'crypto-js'
 
 /** 请求配置常量 */
 const REQUEST_TIMEOUT = 15000
@@ -19,7 +19,6 @@ let unauthorizedTimer: NodeJS.Timeout | null = null
 /** 扩展 AxiosRequestConfig */
 interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
   showErrorMessage?: boolean
-
 }
 
 const { VITE_API_URL, VITE_WITH_CREDENTIALS } = import.meta.env
@@ -151,28 +150,28 @@ function delay(ms: number) {
   return new Promise((resolve) => setTimeout(resolve, ms))
 }
 
-function objectToFormData(obj:any):FormData {
+function objectToFormData(obj: any): FormData {
   if (obj instanceof FormData) {
     return obj
   }
-  const formData = new FormData();
+  const formData = new FormData()
   for (const key in obj) {
     if (obj.hasOwnProperty(key)) {
       if (Array.isArray(obj[key])) {
         // 处理数组
         obj[key].forEach((item, index) => {
-          formData.append(`${key}[${index}]`, item);
-        });
+          formData.append(`${key}[${index}]`, item)
+        })
       } else if (typeof obj[key] === 'object' && !(obj[key] instanceof File)) {
         // 处理嵌套对象和文件
-        formData.append(key, JSON.stringify(obj[key]));
+        formData.append(key, JSON.stringify(obj[key]))
       } else {
-        formData.append(key, obj[key]);
+        formData.append(key, obj[key])
       }
     }
   }
 
-  return formData;
+  return formData
 }
 
 /** 请求函数 */
@@ -189,7 +188,6 @@ async function request<T = any>(config: ExtendedAxiosRequestConfig): Promise<T>
 
   try {
     const res = await axiosInstance.request<Api.Http.BaseResponse<T>>(config)
-    console.log(`%c res.data == `, 'background:#41b883 ; padding:1px; color:#fff', res.data);
     return res.data.data as T
   } catch (error) {
     if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) {
@@ -205,16 +203,14 @@ async function request<T = any>(config: ExtendedAxiosRequestConfig): Promise<T>
  * @param {Object} params 待签名参数对象
  * @returns {string} MD5签名结果
  */
-function getSign(params:Object) {
+function getSign(params: object) {
   const filteredParams = Object.fromEntries(
-      Object.entries(params).filter(([_, v]) => v !== null && v !== undefined)
-  );
-  const sortedKeys = Object.keys(filteredParams).sort();
-  const queryString = sortedKeys
-      .map(key => `${key}=${filteredParams[key]}`)
-      .join('&');
-  console.log(`%c queryString == `, 'background:#41b883 ; padding:1px; color:#fff', queryString);
-  return CryptoJS.sha256(queryString).toString().toUpperCase();
+    Object.entries(params).filter(([_, v]) => v !== null && v !== undefined)
+  )
+  const sortedKeys = Object.keys(filteredParams).sort()
+  const queryString = sortedKeys.map((key) => `${key}=${filteredParams[key]}`).join('&')
+  console.log(`%c queryString == `, 'background:#41b883 ; padding:1px; color:#fff', queryString)
+  return CryptoJS.sha256(queryString).toString().toUpperCase()
 }
 
 /** API方法集合 */

+ 67 - 43
web/src/views/auth/forget-password/index.vue

@@ -11,39 +11,61 @@
           <h3 class="title">{{ $t('forgetPassword.title') }}</h3>
           <p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
           <el-form
-              ref="ruleFormRef"
-              :model="form"
-              status-icon
-              :rules="rules"
-              label-width="auto"
-              class="ruleForm"
+            ref="ruleFormRef"
+            :model="form"
+            status-icon
+            :rules="rules"
+            label-width="auto"
+            class="ruleForm"
           >
             <div class="input-wrap">
               <span class="input-label" v-if="showInputLabel">{{ $t('common.phone') }}</span>
               <el-form-item prop="phone">
-              <ElInput :placeholder="$t('forgetPassword.placeholder')" type="text" v-model.trim="form.phone" autocomplete="off"/>
+                <ElInput
+                  :placeholder="$t('forgetPassword.placeholder')"
+                  type="text"
+                  v-model.trim="form.phone"
+                  autocomplete="off"
+                />
               </el-form-item>
             </div>
             <div class="input-wrap">
               <span class="input-label" v-if="showInputLabel">{{ $t('common.verifyCode') }}</span>
               <el-form-item prop="code">
-              <ElInput :placeholder="$t('forgetPassword.placeholder_verify')" type="text" v-model.trim="form.code" style="width: 50%" autocomplete="off"/>
-              <ElButton @click="sendCode" type="primary" style="width: 45%;margin-left: 4%;" :disabled="disable">{{ buttonText }}</ElButton>
+                <ElInput
+                  :placeholder="$t('forgetPassword.placeholder_verify')"
+                  type="text"
+                  v-model.trim="form.code"
+                  style="width: 50%"
+                  autocomplete="off"
+                />
+                <ElButton
+                  @click="sendCode"
+                  type="primary"
+                  style="width: 45%; margin-left: 4%"
+                  :disabled="disable"
+                  >{{ buttonText }}</ElButton
+                >
               </el-form-item>
             </div>
             <div class="input-wrap">
               <span class="input-label" v-if="showInputLabel">{{ $t('common.newPassword') }}</span>
               <el-form-item prop="password">
-              <ElInput :placeholder="$t('forgetPassword.placeholder_new_password')" type="password" v-model.trim="form.password" autocomplete="new-password"/>
+                <ElInput
+                  :placeholder="$t('forgetPassword.placeholder_new_password')"
+                  type="password"
+                  v-model.trim="form.password"
+                  autocomplete="new-password"
+                />
               </el-form-item>
             </div>
             <div style="margin-top: 15px">
               <ElButton
-                  class="login-btn"
-                  type="primary"
-                  @click="submitForm(ruleFormRef)"
-                  :loading="loading"
-                  v-ripple
+                class="login-btn"
+                type="primary"
+                @click="submitForm(ruleFormRef)"
+                :loading="loading"
+                v-ripple
               >
                 {{ $t('forgetPassword.submitBtnText') }}
               </ElButton>
@@ -65,7 +87,7 @@
   import AppConfig from '@/config'
   import { RoutesAlias } from '@/router/routesAlias'
   import { watch, ref, onMounted, onBeforeUnmount } from 'vue'
-  import { FormRules, FormInstance, ElMessage } from "element-plus";
+  import { FormRules, FormInstance, ElMessage } from 'element-plus'
   import { UserService } from '@/api/usersApi'
 
   defineOptions({ name: 'ForgetPassword' })
@@ -78,7 +100,7 @@
   const disable = ref(true)
   const COUNTDOWN_SECONDS = 60
   const countdown = ref(0)
-  const timer = ref< NodeJS.Timeout>()
+  const timer = ref<NodeJS.Timeout>()
   const isCounting = ref(false)
   const phonePattern = /^1\d{10}$/
 
@@ -101,7 +123,7 @@
     }
   })
 
-  const startCountdown = (seconds:number) => {
+  const startCountdown = (seconds: number) => {
     isCounting.value = true
     countdown.value = seconds
     const endTime = Date.now() + seconds * 1000
@@ -129,11 +151,11 @@
   }
 
   const canSendCode = () => {
-     if (form.phone && phonePattern.test(form.phone)) {
-       disable.value = false
-     } else {
-       disable.value = true
-     }
+    if (form.phone && phonePattern.test(form.phone)) {
+      disable.value = false
+    } else {
+      disable.value = true
+    }
   }
 
   const rules = reactive<FormRules>({
@@ -141,30 +163,30 @@
       { required: true, message: '请输入手机号', trigger: 'blur' },
       { pattern: phonePattern, message: '手机号格式有误', trigger: 'blur' }
     ],
-    code: [
-      { required: true, message: '请输入验证码', trigger: 'blur' },
-    ],
+    code: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
     password: [
       { required: true, message: '请输入新密码', trigger: 'blur' },
       { min: 6, message: '密码最少6位', trigger: 'blur' }
-    ],
+    ]
   })
 
   const form = reactive<Form.FindPassword>({
     phone: '',
     code: '',
-    password: '',
+    password: ''
   })
 
   const sendCode = async () => {
     if (phonePattern.test(form.phone)) {
-      UserService.sendCode(form.phone).then(() => {
-        ElMessage.success('验证码发送成功')
-        disable.value = true
-        startCountdown(COUNTDOWN_SECONDS)
-      }).catch(() => {
-        ElMessage.error('验证码发送失败')
-      })
+      UserService.sendCode(form.phone)
+        .then(() => {
+          ElMessage.success('验证码发送成功')
+          disable.value = true
+          startCountdown(COUNTDOWN_SECONDS)
+        })
+        .catch(() => {
+          ElMessage.error('验证码发送失败')
+        })
     }
   }
 
@@ -176,14 +198,16 @@
     if (!formEl) return
     await formEl.validate((valid, fields) => {
       if (valid) {
-        UserService.findPassword(form).then(() => {
-          ElMessage.success('密码重置成功')
-          setTimeout(() => {
-            router.push(RoutesAlias.Login)
-          }, 2000)
-        }).catch(() => {
-          ElMessage.error('操作失败')
-        })
+        UserService.findPassword(form)
+          .then(() => {
+            ElMessage.success('密码重置成功')
+            setTimeout(() => {
+              router.push(RoutesAlias.Login)
+            }, 2000)
+          })
+          .catch(() => {
+            ElMessage.error('操作失败')
+          })
       }
     })
   }

+ 6 - 7
web/src/views/auth/login/index.vue

@@ -43,7 +43,6 @@
             @keyup.enter="handleSubmit"
             style="margin-top: 25px"
           >
-
             <ElFormItem prop="username">
               <ElInput :placeholder="$t('login.placeholder[0]')" v-model.trim="formData.username" />
             </ElFormItem>
@@ -94,12 +93,12 @@
               </ElButton>
             </div>
 
-<!--            <div class="footer">-->
-<!--              <p>-->
-<!--                {{ $t('login.noAccount') }}-->
-<!--                <RouterLink :to="RoutesAlias.Register">{{ $t('login.register') }}</RouterLink>-->
-<!--              </p>-->
-<!--            </div>-->
+            <!--            <div class="footer">-->
+            <!--              <p>-->
+            <!--                {{ $t('login.noAccount') }}-->
+            <!--                <RouterLink :to="RoutesAlias.Register">{{ $t('login.register') }}</RouterLink>-->
+            <!--              </p>-->
+            <!--            </div>-->
           </ElForm>
         </div>
       </div>

+ 1 - 2
web/src/views/dashboard/console/index.vue

@@ -36,12 +36,11 @@
   import TodoList from './widget/TodoList.vue'
   import AboutProject from './widget/AboutProject.vue'
   import { useCommon } from '@/composables/useCommon'
-  import {UserService} from "@/api/usersApi";
+  import { UserService } from '@/api/usersApi'
 
   defineOptions({ name: 'Console' })
 
   useCommon().scrollToTop()
-
 </script>
 
 <style lang="scss" scoped>

+ 269 - 0
web/src/views/school/comment/index.vue

@@ -0,0 +1,269 @@
+<template>
+  <div class="page-content">
+    <h1 class="title">留言墙</h1>
+    <p class="desc">每一份留言都记录了您的想法,也为我们提供了珍贵的回忆</p>
+
+    <div class="list">
+      <ul class="offset">
+        <li
+          class="comment-box"
+          v-for="item in commentList"
+          :key="item.id"
+          :style="{ background: randomColor() }"
+          @click="openDrawer(item)"
+        >
+          <p class="date">{{ item.date }}</p>
+          <p class="content">{{ item.content }}</p>
+          <div class="bottom">
+            <div class="left">
+              <span><i class="iconfont-sys">&#xe6eb;</i>{{ item.collection }}</span>
+              <span><i class="iconfont-sys">&#xe6e9;</i>{{ item.comment }}</span>
+            </div>
+            <div class="right">
+              <span>{{ item.userName }}</span>
+            </div>
+          </div>
+        </li>
+      </ul>
+    </div>
+
+    <ElDrawer
+      lDrawer
+      v-model="showDrawer"
+      :lock-scroll="false"
+      :size="360"
+      modal-class="comment-modal"
+    >
+      <template #header>
+        <h4>详情</h4>
+      </template>
+      <template #default>
+        <div class="drawer-default">
+          <div class="comment-box" :style="{ background: randomColor() }">
+            <p class="date">{{ clickItem.date }}</p>
+            <p class="content">{{ clickItem.content }}</p>
+            <div class="bottom">
+              <div class="left">
+                <span><i class="iconfont-sys">&#xe6eb;</i>{{ clickItem.collection }}</span>
+                <span><i class="iconfont-sys">&#xe6e9;</i>{{ clickItem.comment }}</span>
+              </div>
+              <div class="right">
+                <span>{{ clickItem.userName }}</span>
+              </div>
+            </div>
+          </div>
+
+          <!-- 评论组件 -->
+          <CommentWidget />
+        </div>
+      </template>
+      <template #footer>
+        <div>
+          <!-- <ElButton @click="cancelClick">cancel</ElButton> -->
+          <!-- <ElButton type="primary" @click="confirmClick">confirm</ElButton> -->
+        </div>
+      </template>
+    </ElDrawer>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { commentList } from '@/mock/temp/commentList'
+  const showDrawer = ref(false)
+
+  defineOptions({ name: 'ArticleComment' })
+
+  // const colorList = reactive([
+  //   'rgba(216, 248, 255, 0.8)',
+  //   'rgba(253, 223, 217, 0.8)',
+  //   'rgba(252, 230, 240, 0.8)',
+  //   'rgba(211, 248, 240, 0.8)',
+  //   'rgba(255, 234, 188, 0.8)',
+  //   'rgba(245, 225, 255, 0.8)',
+  //   'rgba(225, 230, 254, 0.8)'
+  // ])
+
+  const colorList = reactive([
+    '#D8F8FF',
+    '#FDDFD9',
+    '#FCE6F0',
+    '#D3F8F0',
+    '#FFEABC',
+    '#F5E1FF',
+    '#E1E6FE'
+  ])
+
+  let lastColor: string | null = null
+
+  const randomColor = () => {
+    let newColor: string
+
+    do {
+      const index = Math.floor(Math.random() * colorList.length)
+      newColor = colorList[index]
+    } while (newColor === lastColor)
+
+    lastColor = newColor
+    return newColor
+  }
+
+  const clickItem = ref({
+    id: 1,
+    date: '2024-9-3',
+    content: '加油!学好Node 自己写个小Demo',
+    collection: 5,
+    comment: 8,
+    userName: '匿名'
+  })
+
+  const openDrawer = (item: any) => {
+    showDrawer.value = true
+    clickItem.value = item
+  }
+</script>
+
+<style lang="scss" scoped>
+  .page-content {
+    background-color: transparent !important;
+    box-shadow: none !important;
+
+    :deep(.comment-modal) {
+      background-color: transparent;
+    }
+
+    .title {
+      margin-top: 20px;
+      font-size: 36px;
+      font-weight: 500;
+    }
+
+    .desc {
+      margin-top: 15px;
+      font-size: 14px;
+      color: var(--art-text-gray-600);
+    }
+
+    .list {
+      margin-top: 40px;
+
+      .offset {
+        display: flex;
+        flex-wrap: wrap;
+        width: calc(100% + 16px);
+      }
+    }
+
+    .comment-box {
+      position: relative;
+      box-sizing: border-box;
+      width: calc(20% - 16px);
+      aspect-ratio: 16 / 12;
+      padding: 16px;
+      margin: 0 16px 16px 0;
+      cursor: pointer;
+      background-color: #eae2cb;
+      transition: all 0.3s;
+
+      &:hover {
+        transform: translateY(-5px);
+      }
+
+      .date {
+        font-size: 12px;
+        color: #949494;
+      }
+
+      .content {
+        margin-top: 16px;
+        font-size: 14px;
+        color: #333;
+      }
+
+      .bottom {
+        position: absolute;
+        bottom: 16px;
+        left: 0;
+        box-sizing: border-box;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        width: 100%;
+        padding: 0 16px;
+
+        .left {
+          display: flex;
+          align-items: center;
+
+          span {
+            display: flex;
+            align-items: center;
+            margin-right: 20px;
+            font-size: 12px;
+            color: #949494;
+
+            i {
+              margin-right: 5px;
+            }
+          }
+        }
+
+        .right {
+          span {
+            font-size: 14px;
+            color: #333;
+          }
+        }
+      }
+    }
+
+    .drawer-default {
+      .comment-box {
+        width: 100%;
+
+        &:hover {
+          transform: translateY(0);
+        }
+      }
+    }
+  }
+
+  @media screen and (max-width: $device-notebook) {
+    .page-content {
+      .comment-box {
+        width: calc(25% - 16px);
+      }
+    }
+  }
+
+  @media screen and (max-width: $device-ipad-pro) {
+    .page-content {
+      .comment-box {
+        width: calc(33.333% - 16px);
+      }
+    }
+  }
+
+  @media screen and (max-width: $device-ipad) {
+    .page-content {
+      .comment-box {
+        width: calc(50% - 16px);
+      }
+    }
+  }
+
+  @media screen and (max-width: $device-phone) {
+    .page-content {
+      .comment-box {
+        width: calc(100% - 16px);
+      }
+    }
+  }
+
+  .dark {
+    .page-content {
+      .comment-box {
+        color: #333 !important;
+      }
+    }
+  }
+</style>

+ 116 - 0
web/src/views/school/detail/index.vue

@@ -0,0 +1,116 @@
+<template>
+  <div class="article-detail page-content">
+    <div class="content">
+      <h1>{{ articleTitle }}</h1>
+      <div class="markdown-body" v-highlight v-html="articleHtml"></div>
+    </div>
+    <ArtBackToTop />
+  </div>
+</template>
+
+<script setup lang="ts">
+  import '@/assets/styles/markdown.scss'
+  import '@/assets/styles/one-dark-pro.scss'
+  import { useCommon } from '@/composables/useCommon'
+  import axios from 'axios'
+  // import 'highlight.js/styles/atom-one-dark.css';
+  // import 'highlight.js/styles/vs2015.css';
+
+  defineOptions({ name: 'ArticleDetail' })
+
+  const articleId = ref(0)
+  const router = useRoute()
+  const articleTitle = ref('')
+  const articleHtml = ref('')
+
+  onMounted(() => {
+    useCommon().scrollToTop()
+    articleId.value = Number(router.query.id)
+    getArticleDetail()
+  })
+
+  const getArticleDetail = async () => {
+    if (articleId.value) {
+      const res = await axios.get('https://www.qiniu.lingchen.kim/blog_detail.json')
+      if (res.data.code === 200) {
+        articleTitle.value = res.data.data.title
+        articleHtml.value = res.data.data.html_content
+      }
+    }
+  }
+</script>
+
+<style lang="scss">
+  .article-detail {
+    .content {
+      max-width: 800px;
+      margin: auto;
+      margin-top: 60px;
+
+      .markdown-body {
+        margin-top: 60px;
+
+        img {
+          width: 100%;
+          border: 1px solid var(--art-gray-200);
+        }
+
+        pre {
+          position: relative;
+
+          &:hover {
+            .copy-button {
+              opacity: 1;
+            }
+          }
+
+          &::before {
+            position: absolute;
+            top: 0;
+            left: 50px;
+            width: 1px;
+            height: 100%;
+            content: '';
+            background: #0a0a0e;
+          }
+        }
+
+        .code-wrapper {
+          overflow-x: auto;
+        }
+
+        .line-number {
+          position: sticky;
+          left: 0;
+          z-index: 2;
+          box-sizing: border-box;
+          display: inline-block;
+          width: 50px;
+          margin-right: 10px;
+          font-size: 14px;
+          color: #9e9e9e;
+          text-align: center;
+        }
+
+        .copy-button {
+          position: absolute;
+          top: 6px;
+          right: 6px;
+          z-index: 1;
+          width: 40px;
+          height: 40px;
+          font-size: 20px;
+          line-height: 40px;
+          color: #999;
+          text-align: center;
+          cursor: pointer;
+          background-color: #000;
+          border: none;
+          border-radius: 8px;
+          opacity: 0;
+          transition: all 0.2s;
+        }
+      }
+    }
+  }
+</style>

+ 297 - 0
web/src/views/school/edit.vue

@@ -0,0 +1,297 @@
+<template>
+  <ElForm ref="formRef" :model="formData" :rules="rules" label-width="auto">
+    <el-row :gutter="20">
+      <el-col :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="名称" prop="name">
+          <ElInput v-model="formData.name" maxlength="20" type="text" />
+        </ElFormItem>
+      </el-col>
+
+      <el-col :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="地区" prop="distinct">
+          <el-cascader
+              v-model="formData.distinct"
+              :options="cityJson"
+              filterable
+              placeholder="可搜索"
+              clearable
+              style="width: 100%"
+          />
+        </ElFormItem>
+      </el-col>
+
+      <el-col :span="24">
+        <ElFormItem label="详细地址" prop="address">
+          <ElInput v-model="formData.address" maxlength="255" type="textarea" />
+        </ElFormItem>
+      </el-col>
+
+      <el-col :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="在校人数" prop="person_num">
+          <ElInput v-model="formData.person_num" maxlength="32" type="text" />
+        </ElFormItem>
+      </el-col>
+
+      <el-col :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="负责人" prop="bind_user_id">
+          <ElInput v-model="formData.bind_user_id" type="number" />
+        </ElFormItem>
+      </el-col>
+
+      <el-col :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="是否有饿了么校内站" prop="is_eleme_in_school">
+          <el-select v-model="formData.is_eleme_in_school" 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 :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="是否有饿了么校外站" prop="is_eleme_out_school">
+          <el-select v-model="formData.is_eleme_out_school" 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 :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="是否有美团校内站" prop="is_meituan_in_school">
+          <el-select v-model="formData.is_meituan_in_school" 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 :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="是否有美团校外站" prop="is_meituan_out_school">
+          <el-select v-model="formData.is_meituan_out_school" 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 :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="是否能上楼" prop="can_go_upstairs">
+          <el-select v-model="formData.can_go_upstairs" 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 :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="是否合作" prop="is_cooperate">
+          <el-select v-model="formData.is_cooperate" 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 :xs="24" :lg="8" :sm="12">
+        <ElFormItem label="是否允许骑电动车" prop="can_ride">
+          <el-select v-model="formData.can_ride" 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 :span="24">
+        <ElFormItem label="宿舍分布情况" prop="dormitory_distribution">
+          <ElInput v-model="formData.dormitory_distribution" maxlength="1000" type="textarea" />
+        </ElFormItem>
+      </el-col>
+
+      <el-col :span="24">
+        <ElFormItem label="校门口取餐点离宿舍情况" prop="qucan_station_distribution">
+          <ElInput v-model="formData.qucan_station_distribution" maxlength="1000" type="textarea" />
+        </ElFormItem>
+      </el-col>
+
+      <el-col :span="24">
+        <ElFormItem label="校外商圈情况" prop="out_business_description">
+          <ElInput v-model="formData.out_business_description" maxlength="1000" type="textarea" />
+        </ElFormItem>
+      </el-col>
+
+      <el-col :span="24">
+        <ElFormItem label="备注" prop="memo">
+          <ElInput 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 { schoolApi as Api } from '@/api/schoolApi'
+  import { onMounted } from 'vue'
+  import { router } from '@/router'
+  import { cityJson } from '@/router/utils/city'
+  import { ElMessageBox } from 'element-plus'
+  import { RoutesAlias } from '@/router/routesAlias'
+
+  // 表单实例
+  const formRef = ref<FormInstance>()
+
+  const DefaultData = <Form.School>{
+    name: '',
+    distinct:[],
+    address: '',
+    person_num: '',
+    bind_user_id: 0,
+    is_eleme_in_school: 0,
+    is_eleme_out_school: 0,
+    is_meituan_in_school: 0,
+    is_meituan_out_school: 0,
+    can_go_upstairs: 0,
+    is_cooperate: 0,
+    can_ride: 0,
+    dormitory_distribution: '',
+    qucan_station_distribution: '',
+    out_business_description: '',
+    memo: ''
+  }
+  // 表单数据
+  const formData = reactive<Form.School>({...DefaultData})
+
+  // 表单验证规则
+  const rules: FormRules = {
+    name: [
+      { required: true, message: '请输入名称', trigger: 'blur' },
+      { max: 20, message: '长度最多20个字符', trigger: 'blur' }
+    ],
+    distinct: [
+      { required: true, message: '请选择地区', trigger: 'blur' },
+    ],
+    address: [
+      { required: true, message: '请输入详细地址', trigger: 'blur' },
+      { max: 255, message: '长度最多255个字符', trigger: 'blur' }
+    ],
+    person_num: [
+      { required: true, message: '请输入在校人数', trigger: 'blur' },
+      { max: 32, message: '长度最多32个字符', trigger: 'blur' }
+    ],
+    bind_user_id: [{ required: true, message: '请输入负责人', trigger: 'blur' }],
+    dormitory_distribution: [{ max: 1000, message: '长度最多1000个字符', trigger: 'blur' }],
+    qucan_station_distribution: [{ max: 1000, message: '长度最多1000个字符', trigger: 'blur' }],
+    out_business_description: [{ max: 1000, message: '长度最多1000个字符', trigger: 'blur' }],
+    memo: [{ max: 255, message: '长度最多255个字符', trigger: 'blur' }]
+  }
+
+  const route = useRoute()
+  let id = 0
+
+  // 初始化表单数据
+  const initFormData = () => {
+    if (id == 0) {
+      Object.assign(formData, DefaultData)
+      return
+    }
+    Api.info(id).then((res) => {
+      Object.assign(formData, res)
+    })
+  }
+
+  onMounted(() => {
+    id = route.query.id ? parseInt(route.query.id as string) : 0
+    initFormData()
+    nextTick(() => {
+      formRef.value?.clearValidate()
+    })
+  })
+
+  const afterOk = () => {
+    ElMessageBox.confirm('操作成功,是否返回列表', 'Success', {
+      confirmButtonText: '返回列表',
+      cancelButtonText: '留在此页',
+      type: 'info'
+    })
+      .then(() => {
+        router.push({ path: RoutesAlias.SchoolList })
+      })
+      .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>

+ 358 - 0
web/src/views/school/list/index.vue

@@ -0,0 +1,358 @@
+<!-- 用户管理 -->
+<!-- art-full-height 自动计算出页面剩余高度 -->
+<!-- art-table-card 一个符合系统样式的 class,同时自动撑满剩余高度 -->
+<!-- 更多 useTable 使用示例请移步至 功能示例 下面的 高级表格示例 -->
+<template>
+  <div class="user-page art-full-height">
+    <!-- 搜索栏 -->
+    <UserSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams"></UserSearch>
+
+    <ElCard class="art-table-card" shadow="never">
+      <!-- 表格头部 -->
+      <ArtTableHeader v-model:columns="columnChecks" @refresh="refreshData">
+        <template #left>
+          <ElButton @click="edit()" v-ripple v-auth="120101">新增校区</ElButton>
+        </template>
+      </ArtTableHeader>
+
+      <!-- 表格 -->
+      <ArtTable
+        :loading="loading"
+        :data="data"
+        :columns="columns"
+        :pagination="pagination"
+        @selection-change="handleSelectionChange"
+        @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>
+
+      <!-- 用户弹窗 -->
+      <!--      <UserDialog-->
+      <!--        v-model:visible="dialogVisible"-->
+      <!--        :type="dialogType"-->
+      <!--        :user-data="currentUserData"-->
+      <!--        :role-list="roleList"-->
+      <!--        @submit="handleDialogSubmit"-->
+      <!--      />-->
+    </ElCard>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import ArtButtonTable from '@/components/core/forms/art-button-table/index.vue'
+  import { ACCOUNT_TABLE_DATA } from '@/mock/temp/formData'
+  import { ElMessageBox, ElMessage, ElTag, ElImage, ElButton, ElInput } from 'element-plus'
+  import { useTable } from '@/composables/useTable'
+  import { schoolApi } from '@/api/schoolApi'
+  import UserSearch from './user-search.vue'
+  import { useUserStore } from '@/store/modules/user'
+  import { useWorktabStore } from '@/store/modules/worktab'
+  import SchoolListItem = Api.School.SchoolListItem
+  import { render } from 'vue'
+  import EmojiText from '@utils/ui/emojo'
+  import { router } from '@/router'
+  import { RoutesAlias } from '@/router/routesAlias'
+
+  defineOptions({ name: 'User' })
+
+  const worktabStore = useWorktabStore()
+
+  type UserListItem = Api.User.UserListItem
+  const { list } = schoolApi
+
+  // 弹窗相关
+  const dialogType = ref<Form.DialogType>('add')
+  const dialogVisible = ref(false)
+  const currentUserData = ref<Partial<UserListItem>>({})
+
+  // 选中行
+  const selectedRows = ref<UserListItem[]>([])
+
+  // 搜索表单
+  const searchForm = ref({
+    name: '',
+    is_cooperate: -1,
+    address: []
+  })
+
+  const {
+    columns,
+    columnChecks,
+    data,
+    loading,
+    pagination,
+    getData,
+    searchParams,
+    resetSearchParams,
+    handleSizeChange,
+    handleCurrentChange,
+    refreshData
+  } = useTable<SchoolListItem>({
+    // 核心配置
+    core: {
+      apiFn: list,
+      apiParams: {
+        current: 1,
+        size: 20,
+        ...searchForm.value
+      },
+      // 排除 apiParams 中的属性
+      excludeParams: [],
+      columnsFactory: () => [
+        { prop: 'name', label: '学校(校区)' },
+        { prop: 'area', label: '地区', formatter: (row) => row.province + row.city + row.area },
+        { prop: 'address', label: '详细地址' },
+        {
+          prop: 'canteen',
+          label: '关联食堂',
+          // sortable: true,
+          // checked: false, // 隐藏列
+          formatter: (row) => {
+            return h(ElButton, { type: 'primary' }, () => '查看')
+          }
+        },
+        {
+          prop: 'concat',
+          label: '关系人',
+          formatter: (row) => {
+            return h(ElButton, { type: 'primary' }, () => '查看')
+          }
+        },
+        {
+          prop: 'is_cooperate',
+          label: '合作状态',
+          formatter: (row) => {
+            return h(ElTag, { type: row.is_cooperate ? 'success' : 'danger' }, () =>
+              row.is_cooperate ? '已合作' : '未合作'
+            )
+          }
+        },
+        { prop: 'person_num', label: '在校人数', useSlot: true },
+        { prop: 'is_eleme_in_school', label: '是否有饿了么校内站', useSlot: true },
+        {
+          prop: 'is_eleme_out_school',
+          label: '是否有饿了么校外站',
+          formatter: (row) => {
+            return h(ElTag, { type: row.is_eleme_out_school ? 'success' : 'danger' }, () =>
+              row.is_eleme_out_school ? '有' : '无'
+            )
+          }
+        },
+        {
+          prop: 'is_meituan_in_school',
+          label: '是否有美团校内站',
+          formatter: (row) => {
+            return h(ElTag, { type: row.is_meituan_in_school ? 'success' : 'danger' }, () =>
+              row.is_meituan_in_school ? '有' : '无'
+            )
+          }
+        },
+        {
+          prop: 'is_meituan_out_school',
+          label: '是否有美团校外站',
+          formatter: (row) => {
+            return h(ElTag, { type: row.is_meituan_out_school ? 'success' : 'danger' }, () =>
+              row.is_meituan_out_school ? '有' : '无'
+            )
+          }
+        },
+        {
+          prop: 'can_go_upstairs',
+          label: '是否能上楼',
+          formatter: (row) => {
+            return h(ElTag, { type: row.can_go_upstairs ? 'success' : 'danger' }, () =>
+              row.can_go_upstairs ? '不能' : '能'
+            )
+          }
+        },
+        {
+          prop: 'can_ride',
+          label: '是否允许骑电动车',
+          formatter: (row) => {
+            return h(ElTag, { type: row.can_ride ? 'success' : 'danger' }, () =>
+              row.can_ride ? '不能' : '能'
+            )
+          }
+        },
+        {
+          prop: 'dormitory_distribution',
+          label: '宿舍分布情况',
+          formatter: (row) => {
+            return h(ElTag, { type: row.dormitory_distribution ? 'success' : 'danger' }, () =>
+              row.dormitory_distribution ? '是' : '否'
+            )
+          }
+        },
+        {
+          prop: 'qucan_station_distribution',
+          label: '校门口取餐点离宿舍情况',
+          formatter: (row) => {
+            return h(ElTag, { type: row.qucan_station_distribution ? 'success' : 'danger' }, () =>
+              row.qucan_station_distribution ? '是' : '否'
+            )
+          }
+        },
+        { prop: 'out_business_description', label: '校外商圈情况', showOverflowTooltip: true },
+        { prop: 'memo', label: '备注', showOverflowTooltip: true },
+        {
+          prop: 'operation',
+          label: '操作',
+          width: 120,
+          fixed: 'right', // 固定列
+          formatter: (row) =>
+            useUserStore().checkAuth(110202) &&
+            h('div', [
+              h(ArtButtonTable, {
+                type: 'edit',
+                onClick: () => edit(row.id)
+              }),
+              useUserStore().checkAuth(110203) &&
+                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
+    }
+    schoolApi
+      .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 => {
+    // 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: {
+        id: id
+      }
+    })
+  }
+
+  /**
+   * 删除用户
+   */
+  const deleteUser = (id: number): void => {
+    ElMessageBox.confirm(`确定要删除该学校吗?`, '删除学校', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'error'
+    }).then(() => {
+      schoolApi.delete({ id: id }).then(() => {
+        ElMessage.success(`${EmojiText[200]} 删除成功`)
+        setTimeout(() => {
+          getData()
+        }, 1000)
+      })
+    })
+  }
+
+  /**
+   * 处理弹窗提交事件
+   */
+  const handleDialogSubmit = async () => {
+    try {
+      dialogVisible.value = false
+      currentUserData.value = {}
+      // 延迟更新 不然数据可能没更新
+      setTimeout(() => {
+        refreshData()
+      }, 1000)
+    } catch (error) {
+      console.error('提交失败:', error)
+    }
+  }
+
+  /**
+   * 处理表格行选择变化
+   */
+  const handleSelectionChange = (selection: UserListItem[]): void => {
+    selectedRows.value = selection
+    console.log('选中行数据:', selectedRows.value)
+  }
+</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>

+ 92 - 0
web/src/views/school/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>
+  }
+  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: 'address',
+      type: 'cascader',
+      props: {
+        options: cityJson,
+        style: { width: '280px' },
+        filterable: true,
+        placeholder: '可搜索',
+        clearable: true,
+        props: props1
+      }
+    },
+    {
+      label: '是否合作',
+      key: 'is_cooperate',
+      type: 'select',
+      filterable: true,
+      props: {
+        options: [
+          { label: '全部', value: -1 },
+          { label: '已合作', value: 1 },
+          { label: '未合作', value: 0 }
+        ]
+      }
+    }
+  ])
+
+  // 事件
+  function handleReset() {
+    console.log('重置表单')
+    emit('reset')
+  }
+
+  async function handleSearch() {
+    await searchBarRef.value.validate()
+    emit('search', formData.value)
+    console.log('表单数据', formData.value)
+  }
+</script>

+ 359 - 0
web/src/views/school/publish/index.vue

@@ -0,0 +1,359 @@
+<template>
+  <div class="article-edit">
+    <div>
+      <div class="editor-wrap">
+        <!-- 文章标题、类型 -->
+        <ElRow :gutter="10">
+          <ElCol :span="18">
+            <ElInput
+              v-model.trim="articleName"
+              placeholder="请输入文章标题(最多100个字符)"
+              maxlength="100"
+            />
+          </ElCol>
+          <ElCol :span="6">
+            <ElSelect v-model="articleType" placeholder="请选择文章类型" filterable>
+              <ElOption
+                v-for="item in articleTypes"
+                :key="item.id"
+                :label="item.name"
+                :value="item.id"
+              />
+            </ElSelect>
+          </ElCol>
+        </ElRow>
+
+        <!-- 富文本编辑器 -->
+        <ArtWangEditor class="el-top" v-model="editorHtml" />
+
+        <div class="form-wrap">
+          <h2>发布设置</h2>
+          <!-- 图片上传 -->
+          <ElForm>
+            <ElFormItem label="封面">
+              <div class="el-top upload-container">
+                <ElUpload
+                  class="cover-uploader"
+                  :action="uploadImageUrl"
+                  :headers="uploadHeaders"
+                  :show-file-list="false"
+                  :on-success="onSuccess"
+                  :on-error="onError"
+                  :before-upload="beforeUpload"
+                >
+                  <div v-if="!cover" class="upload-placeholder">
+                    <ElIcon class="upload-icon"><Plus /></ElIcon>
+                    <div class="upload-text">点击上传封面</div>
+                  </div>
+                  <img v-else :src="cover" class="cover-image" />
+                </ElUpload>
+                <div class="el-upload__tip">建议尺寸 16:9,jpg/png 格式</div>
+              </div>
+            </ElFormItem>
+            <ElFormItem label="可见">
+              <ElSwitch v-model="visible" />
+            </ElFormItem>
+          </ElForm>
+
+          <div style="display: flex; justify-content: flex-end">
+            <ElButton type="primary" @click="submit" style="width: 100px">
+              {{ pageMode === PageModeEnum.Edit ? '保存' : '发布' }}
+            </ElButton>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- <div class="outline-wrap">
+        <div class="item" v-for="(item, index) in outlineList" :key="index">
+          <p :class="`level${item.level}`">{{ item.text }}</p>
+        </div>
+      </div> -->
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { Plus } from '@element-plus/icons-vue'
+  import { ApiStatus } from '@/utils/http/status'
+  import { ElMessage } from 'element-plus'
+  import { useUserStore } from '@/store/modules/user'
+  import EmojiText from '@/utils/ui/emojo'
+  import { PageModeEnum } from '@/enums/formEnum'
+  import axios from 'axios'
+  import { useCommon } from '@/composables/useCommon'
+
+  defineOptions({ name: 'ArticlePublish' })
+
+  const route = useRoute()
+
+  const userStore = useUserStore()
+  let { accessToken } = userStore
+
+  // 上传路径
+  const uploadImageUrl = `${import.meta.env.VITE_API_URL}/api/common/upload`
+  // 传递 token
+  const uploadHeaders = { Authorization: accessToken }
+
+  let pageMode: PageModeEnum = PageModeEnum.Add // 页面类型 新增 | 编辑
+  const articleName = ref('') // 文章标题
+  const articleType = ref() // 文章类型
+  const articleTypes = ref() // 类型列表
+  const editorHtml = ref('') // 编辑器内容
+  const createDate = ref('') // 创建时间
+  const cover = ref('') // 图片
+  const visible = ref(true) // 可见
+  // const outlineList = ref()
+
+  onMounted(() => {
+    useCommon().scrollToTop()
+    getArticleTypes()
+    initPageMode()
+  })
+
+  // 初始化页面类型 新增 | 编辑
+  const initPageMode = () => {
+    const { id } = route.query
+    pageMode = id ? PageModeEnum.Edit : PageModeEnum.Add
+    if (pageMode === PageModeEnum.Edit && id) {
+      initEditArticle()
+    } else {
+      initAddArticle()
+    }
+  }
+
+  // 初始化编辑文章的逻辑
+  const initEditArticle = () => {
+    getArticleDetail()
+  }
+
+  // 初始化新增文章逻辑
+  const initAddArticle = () => {
+    createDate.value = formDate(useNow().value)
+  }
+
+  // 获取文章类型
+  const getArticleTypes = async () => {
+    try {
+      const response = await axios.get('https://www.qiniu.lingchen.kim/classify.json')
+      if (response.data.code === 200) {
+        articleTypes.value = response.data.data
+      }
+    } catch (error) {
+      console.error('Error fetching JSON data:', error)
+    }
+    // try {
+    //   const res = await ArticleService.getArticleTypes({})
+    //   if (res.code === ApiStatus.success) {
+    //     articleTypes.value = res.data
+    //   }
+    // } catch (err) { }
+  }
+
+  const getArticleDetail = async () => {
+    const res = await axios.get('https://www.qiniu.lingchen.kim/blog_list.json')
+
+    if (res.data.code === ApiStatus.success) {
+      let { title, blog_class, html_content } = res.data.data
+      articleName.value = title
+      articleType.value = Number(blog_class)
+      editorHtml.value = html_content
+    }
+  }
+
+  // const getOutline = (content: string) => {
+  //   const regex = /<h([1-3])>(.*?)<\/h\1>/g
+  //   const headings = []
+  //   let match
+
+  //   while ((match = regex.exec(content)) !== null) {
+  //     headings.push({ level: match[1], text: match[2] })
+  //   }
+  //   outlineList.value = headings
+  // }
+
+  // 提交
+  const submit = () => {
+    if (pageMode === PageModeEnum.Edit) {
+      editArticle()
+    } else {
+      addArticle()
+    }
+  }
+
+  // 格式化日期
+  const formDate = (date: string | Date): string => {
+    return useDateFormat(date, 'YYYY-MM-DD').value
+  }
+
+  // 验证输入
+  const validateArticle = () => {
+    if (!articleName.value) {
+      ElMessage.error(`请输入文章标题`)
+      return false
+    }
+
+    if (!articleType.value) {
+      ElMessage.error(`请选择文章类型`)
+      return false
+    }
+
+    if (editorHtml.value === '<p><br></p>') {
+      ElMessage.error(`请输入文章内容`)
+      return false
+    }
+
+    if (!cover.value) {
+      ElMessage.error(`请上传图片`)
+      return false
+    }
+
+    return true
+  }
+
+  // 添加文章
+  const addArticle = async () => {
+    try {
+      if (!validateArticle()) return
+      editorHtml.value = delCodeTrim(editorHtml.value)
+    } catch (err) {
+      console.error(err)
+    }
+  }
+
+  // 编辑文章
+  const editArticle = async () => {
+    try {
+      if (!validateArticle()) return
+
+      editorHtml.value = delCodeTrim(editorHtml.value)
+    } catch (err) {
+      console.error(err)
+    }
+  }
+
+  const delCodeTrim = (content: string): string => {
+    return content.replace(/(\s*)<\/code>/g, '</code>')
+  }
+
+  const onSuccess = (response: any) => {
+    cover.value = response.data.url
+    ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
+  }
+
+  const onError = () => {
+    ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
+  }
+
+  // 添加上传前的校验
+  const beforeUpload = (file: File) => {
+    const isImage = file.type.startsWith('image/')
+    const isLt2M = file.size / 1024 / 1024 < 2
+
+    if (!isImage) {
+      ElMessage.error('只能上传图片文件!')
+      return false
+    }
+    if (!isLt2M) {
+      ElMessage.error('图片大小不能超过 2MB!')
+      return false
+    }
+    return true
+  }
+</script>
+
+<style lang="scss" scoped>
+  .article-edit {
+    .editor-wrap {
+      max-width: 1000px;
+      margin: 20px auto;
+
+      .el-top {
+        margin-top: 10px;
+      }
+
+      .form-wrap {
+        padding: 20px;
+        margin-top: 20px;
+        background-color: var(--art-main-bg-color);
+        border: 1px solid var(--art-border-color);
+        border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
+
+        h2 {
+          margin-bottom: 20px;
+          font-size: 20px;
+          font-weight: 500;
+        }
+      }
+    }
+
+    .outline-wrap {
+      box-sizing: border-box;
+      width: 280px;
+      padding: 20px;
+      border: 1px solid #e3e3e3;
+      border-radius: 8px;
+
+      .item {
+        p {
+          height: 30px;
+          font-size: 13px;
+          line-height: 30px;
+          cursor: pointer;
+        }
+
+        .level3 {
+          padding-left: 10px;
+        }
+      }
+    }
+
+    .upload-container {
+      .cover-uploader {
+        position: relative;
+        overflow: hidden;
+        cursor: pointer;
+        border-radius: 6px;
+        transition: var(--el-transition-duration);
+
+        &:hover {
+          border-color: var(--el-color-primary);
+        }
+
+        .upload-placeholder {
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          justify-content: center;
+          width: 260px;
+          height: 160px;
+          border: 1px dashed #d9d9d9;
+          border-radius: 6px;
+
+          .upload-icon {
+            font-size: 28px;
+            color: #8c939d;
+          }
+
+          .upload-text {
+            margin-top: 8px;
+            font-size: 14px;
+            color: #8c939d;
+          }
+        }
+
+        .cover-image {
+          display: block;
+          width: 260px;
+          height: 160px;
+          object-fit: cover;
+        }
+      }
+
+      .el-upload__tip {
+        margin-top: 8px;
+        font-size: 12px;
+        color: #666;
+      }
+    }
+  }
+</style>

+ 1 - 3
web/src/views/system/menu/index.vue

@@ -14,9 +14,7 @@
       <ArtTableHeader :showZebra="false" v-model:columns="columnChecks" @refresh="handleRefresh">
         <template #left>
           <!-- 按钮权限:后端控制模式,使用自定义指令 -->
-          <ElButton @click="showModel('menu', null, true)" v-ripple>
-            添加菜单
-          </ElButton>
+          <ElButton @click="showModel('menu', null, true)" v-ripple> 添加菜单 </ElButton>
           <ElButton @click="toggleExpand" v-ripple>
             {{ isExpanded ? '收起' : '展开' }}
           </ElButton>

+ 17 - 15
web/src/views/system/role/index.vue

@@ -119,8 +119,8 @@
   import { formatMenuTitle } from '@/router/utils/utils'
   import { Role, ROLE_LIST_DATA } from '@/mock/temp/formData'
   import { ButtonMoreItem } from '@/components/core/forms/art-button-more/index.vue'
-  import { roleService } from "@/api/roleApi";
-  import {AppRouteRecord} from "@/types";
+  import { roleService } from '@/api/roleApi'
+  import { AppRouteRecord } from '@/types'
 
   defineOptions({ name: 'Role' })
 
@@ -132,7 +132,7 @@
     descr: '',
     create_date: '',
     auth_ids: [],
-    show_ids: [],
+    show_ids: []
   })
   const { menuList } = storeToRefs(useMenuStore())
   const treeRef = ref()
@@ -170,9 +170,11 @@
           isAuth: true,
           checked: auth.checked || false
         }))
-        const newAuthNodes = authNodes.filter((item:any) => item.id)
+        const newAuthNodes = authNodes.filter((item: any) => item.id)
         if (newAuthNodes.length > 0) {
-          processed.children = processed.children ? [...processed.children, ...newAuthNodes] : newAuthNodes
+          processed.children = processed.children
+            ? [...processed.children, ...newAuthNodes]
+            : newAuthNodes
         }
       }
 
@@ -194,15 +196,15 @@
       { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
     ],
     des: [
-        { message: '请输入角色描述', trigger: 'blur' },
-        { max: 255, message: '长度最大255个字符', trigger: 'blur' }
+      { message: '请输入角色描述', trigger: 'blur' },
+      { max: 255, message: '长度最大255个字符', trigger: 'blur' }
     ]
   })
 
   const form = reactive<Form.RoleInfo>({
     id: 0,
     name: '',
-    descr: '',
+    descr: ''
   })
 
   const roleList = ref<Api.Role.RoleInfo[]>([])
@@ -244,7 +246,7 @@
   }
 
   const showPermissionDialog = (row: any) => {
-    console.log(`%c row == `, 'background:#41b883 ; padding:1px; color:#fff', row);
+    console.log(`%c row == `, 'background:#41b883 ; padding:1px; color:#fff', row)
     permissionDialog.value = true
     nowRoleInfo.id = row.id
     nowRoleInfo.name = row.name
@@ -252,7 +254,7 @@
     nowRoleInfo.create_date = row.create_date
     nowRoleInfo.auth_ids = row.auth_ids
     nowRoleInfo.show_ids = row.show_ids
-    console.log(`%c row.show_ids == `, 'background:#41b883 ; padding:1px; color:#fff', row.show_ids);
+    console.log(`%c row.show_ids == `, 'background:#41b883 ; padding:1px; color:#fff', row.show_ids)
     const tree = treeRef.value
     if (!tree) return
     tree.setCheckedKeys(row.show_ids)
@@ -269,10 +271,10 @@
       cancelButtonText: '取消',
       type: 'error'
     }).then(() => {
-        roleService.deleteRole({id})
-        setTimeout(() => {
-          getTableData()
-        }, 1000)
+      roleService.deleteRole({ id })
+      setTimeout(() => {
+        getTableData()
+      }, 1000)
     })
   }
 
@@ -292,7 +294,7 @@
   }
 
   const savePermission = async () => {
-    let tree = treeRef.value;
+    let tree = treeRef.value
     await roleService.saveRoleAuth(nowRoleInfo.id, tree.getCheckedKeys(), tree.getHalfCheckedKeys())
     permissionDialog.value = false
     setTimeout(() => {

+ 18 - 11
web/src/views/system/user/index.vue

@@ -5,7 +5,12 @@
 <template>
   <div class="user-page art-full-height">
     <!-- 搜索栏 -->
-    <UserSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" :role-list="roleList"></UserSearch>
+    <UserSearch
+      v-model="searchForm"
+      @search="handleSearch"
+      @reset="resetSearchParams"
+      :role-list="roleList"
+    ></UserSearch>
 
     <ElCard class="art-table-card" shadow="never">
       <!-- 表格头部 -->
@@ -47,8 +52,8 @@
   import { UserService } from '@/api/usersApi'
   import UserSearch from './modules/user-search.vue'
   import UserDialog from './modules/user-dialog.vue'
-  import {roleService} from "@/api/roleApi";
-  import {useUserStore} from "@/store/modules/user";
+  import { roleService } from '@/api/roleApi'
+  import { useUserStore } from '@/store/modules/user'
 
   defineOptions({ name: 'User' })
 
@@ -73,7 +78,7 @@
   // 用户状态配置
   const USER_STATUS_CONFIG = {
     '1': { type: 'success' as const, text: '正常' },
-    '0': { type: 'danger' as const, text: '拉黑' },
+    '0': { type: 'danger' as const, text: '拉黑' }
   } as const
 
   /**
@@ -129,7 +134,7 @@
           label: '性别',
           sortable: true,
           // checked: false, // 隐藏列
-          formatter: (row) => row.sex ? '女' : '男'
+          formatter: (row) => (row.sex ? '女' : '男')
         },
         {
           prop: 'status',
@@ -150,15 +155,17 @@
           width: 120,
           fixed: 'right', // 固定列
           formatter: (row) =>
-              useUserStore().checkAuth(110101) && h('div', [
+            useUserStore().checkAuth(110101) &&
+            h('div', [
               h(ArtButtonTable, {
                 type: 'edit',
                 onClick: () => showDialog('edit', row)
               }),
-                useUserStore().checkAuth(110102) && h(ArtButtonTable, {
-                type: 'delete',
-                onClick: () => deleteUser(row)
-              })
+              useUserStore().checkAuth(110102) &&
+                h(ArtButtonTable, {
+                  type: 'delete',
+                  onClick: () => deleteUser(row)
+                })
             ])
         }
       ]
@@ -214,7 +221,7 @@
       cancelButtonText: '取消',
       type: 'error'
     }).then(() => {
-      UserService.deleteUser({id: row.id})
+      UserService.deleteUser({ id: row.id })
     })
   }
 

+ 6 - 11
web/src/views/system/user/modules/user-dialog.vue

@@ -23,12 +23,7 @@
       </ElFormItem>
       <ElFormItem label="角色" prop="role">
         <ElSelect v-model="formData.role_name">
-          <ElOption
-            v-for="role in roleList"
-            :key="role.id"
-            :value="role.name"
-            :label="role.name"
-          />
+          <ElOption v-for="role in roleList" :key="role.id" :value="role.name" :label="role.name" />
         </ElSelect>
       </ElFormItem>
     </ElForm>
@@ -103,10 +98,10 @@
     isEdit.value = props.type === 'edit' && props.userData
     const row = props.userData
     Object.assign(formData, {
-      username: isEdit ? row.username || '' : '',
-      phone: isEdit ? row.phone || '' : '',
-      sex: isEdit ? (row.sex ? '女' : '男') : '男',
-      role_name: isEdit ? row.role_name || '' : ''
+      username: isEdit.value ? row.username || '' : '',
+      phone: isEdit.value ? row.phone || '' : '',
+      sex: isEdit.value ? (row.sex ? '女' : '男') : '男',
+      role_name: isEdit.value ? row.role_name || '' : ''
     })
   }
 
@@ -130,7 +125,7 @@
 
     await formRef.value.validate((valid) => {
       if (valid) {
-        let userData:Api.User.UserInfo = {
+        let userData: Api.User.UserInfo = {
           id: props.userData.id,
           username: formData.username,
           password: formData.password,

+ 6 - 3
web/src/views/system/user/modules/user-search.vue

@@ -14,7 +14,7 @@
   import { ref, computed, onMounted, h } from 'vue'
 
   interface Props {
-    modelValue: Record<string, any>,
+    modelValue: Record<string, any>
     roleList: Api.Common.SelectInfo[]
   }
   interface Emits {
@@ -53,10 +53,13 @@
       label: '角色',
       key: 'role_id',
       type: 'select',
-      props: { placeholder: '请选择角色', options: props.roleList.map(item => ({
+      props: {
+        placeholder: '请选择角色',
+        options: props.roleList.map((item) => ({
           label: item.name,
           value: item.id
-        })) }
+        }))
+      }
     }
   ])