yii2+elementUI2+uni-app使用小结

yii2 应用与git设置

我们在 advanced 模板基础上多了 api 这个app

5个应用与目录结构

5个应用如下

  • common 我实际使用的就是2个目录:config 和 models
    • config
      • bootstrap.php 这是启动时必须加载的,注意我们多了别名 @api的设置 Yii::setAlias('@api', dirname(dirname(__DIR__)).'/api');
      • codeception-local.php、test.php、test-local.php 这几个测试用的,我没用
      • params.php 中包含我使用的kartik组件用的图标框架和bootstrap版本配置 'icon-framework' => 'fa','bsVersion' => '4.x',、params-local.php (我是空的)
      • main.php 是各个应用公用的配置(所谓公用配置,就是frontend和backend等都会加载这些配置):语言、时区、组件、模块等;main-local.php 也是各个应用公用的,带 -local 意思这个配置和本地环境有关,即开发时的环境和部署目标的环境不一样就不同,所以通常是公用的数据库组件配置
    • models
      • 数据库表对应model,如 User.php
        因为通常 User 模型类应用中都有,所以我偷懒把一些公用函数作为它的静态方法提供了
    /**
     * 为指定文件名的文件生成完成路径
     * @param string $fileName    将生成(或使用)的文件名
     * @param string $subPath     相对于基础目录的子目录,默认为 upload/ 目录
     * @param bool $absolute      true = 文件系统绝对路径  false = 网络URL
     * @param string $alias       Yii2别名,用于生成基础目录,默认为 @webroot 应用文档根
     * @return string|string[]    完整的路径文件名
     */
    public static function getFilePath($fileName, $subPath = 'upload/', $absolute = false, $alias = '@webroot')
    {
        $absolutePath = Yii::getAlias($alias).'/'.$subPath.$fileName;
        $_SERVER['DOCUMENT_ROOT'] = rtrim($_SERVER['DOCUMENT_ROOT'], '/\\');
        return $absolute ? $absolutePath : str_replace($_SERVER['DOCUMENT_ROOT'], '', $absolutePath);
    }

    /**
     * 为指定的已上传文件生成 img 标签
     * @param $imageUrls  string  按分隔符分隔的图像URL列表
     * @param string $delimiter  分隔符
     * @return string   html文本,包含若干个  标签
     */
    public static function htmlImages($imageUrls, $delimiter = '|')
    {
        $html = '';
        foreach (explode('|', $imageUrls) as $filename) {
            if (!empty($filename)) {
                $html .= Html::img(User::getFilePath($filename, 'upload/', false, '@web'),
                    ['style' => 'width:auto;height:auto;max-width:100%;max-height:100%']);
            }
        }
        return $html ? $html : '(无)
'
; } /** * 从上传目录删除指定文件名的脏文件 * @param $fileNames array 文件名数组 */ public static function deleteDirtyImages($fileNames) { foreach ($fileNames as $fileName) { $fullPath = User::getFilePath($fileName, 'upload/', true); @unlink($fullPath); } } /** * 根据用户名和密码查找用户对象,返回该对象(若对象存在,但访问令牌access_token为空,则同步为该对象生成访问令牌) * @param $username string 用户名 * @param $password string 密码 * @return User|null 用户对象 */ public static function findByUsernamePassword($username, $password) { $model = User::findOne(['username' => $username, 'password_hash' => User::hashPassword($password), 'status' => User::STATUS_ACTIVE]); if ($model !== null) { if (empty($model->access_token)) { $model->access_token = Yii::$app->security->generateRandomString(); if ($model->update(false, ['access_token']) == false) return null; // save token failed, return null } } return $model; } /** * 重新生成访问令牌 access_token * @return false|int */ public function refreshAccessToken() { $this->access_token = Yii::$app->security->generateRandomString(); return $this->update(false, ['access_token']); }
      • 非数据库表的model,如表单model LoginForm.php
      • 非model类,我把用于Excel输出的 XLSXWriter 类放入这里(这个类就一个文件,可以用php5,这个类用到了ZipArchive类,所以composer.json添加"ext-zip": "*" 且文件开头use ZipArchive;),我在 markMergedCell()方法前添加了一个静态方法
    static public function parseMergeRange($strMergeRange)
    {
       // 将 A1:B3 解析成 ['startCellRow' => 0, 'startCellColumn' => 0, 'endCellRow' => 1, 'endCellColumn' => 2]
       $result = [];
       list($leftTop, $rightBottom) = explode(':', $strMergeRange);
       $result['startCellColumn'] = ord($leftTop[0]) - ord('A');  // 最多支持26列!
       $result['startCellRow'] = substr($leftTop, 1) - 1;
       $result['endCellColumn'] = ord($rightBottom[0]) - ord('A');  // 最多支持26列!
       $result['endCellRow'] = substr($rightBottom, 1) - 1;
       return $result;
    } 
    

` ``

  • backend
    我的backend主要是后台数据管理。目前没有完全前后端分离,如果彻底分离,那么应该这里只处理非公共的接口(局限与backend应用的请求)
    • 生成Excel文件并下载(控制器样例, XLSXWriter 支持php5,可以原生生成,支持大量数据,不过功能比较简单,phpoffice/phpspreadsheet 支持读取模板再修改,但需要 php7.2+)
    public function actionExcel($ids)  // XLSXWriter 支持 php5
    {
        $this->layout = false;
        $models = DisinfectRecord::find()->where(['id' => explode(',', $ids)])->all();  // 数据
        if (count($models) == 0) return;  // 数据为空不下载
        $date1 = $models[0]->date;  $date2 = $models[count($models)-1]->date;
        if (strcmp($date1, $date2) > 0) {
            $dateFrom = $date2; $dateTo = $date1;
        } else {
            $dateFrom = $date1; $dateTo = $date2;
        }
        $downloadFileName = '消毒记录#'. $models[0]->address.'#'.$dateFrom.'至'.$dateTo.'.xlsx';  // 文件名
        $writer = new XLSXWriter();         //writer 类
        //设置 header,用于浏览器下载
        header('Content-disposition: attachment; filename="'.XLSXWriter::sanitize_filename($downloadFileName).'"');
        header("Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        header('Content-Transfer-Encoding: binary');
        header('Cache-Control: must-revalidate');
        header('Pragma: public');
        // 表头、每列标题、数据类型
        $caption = [$models[0]->address . '消毒记录(橙区)'];
        $thead = [
            ['A' => '日期', 'B' => '起止时间', 'C' => '', 'D' => '范围', 'E' => '消毒药', 'F' => '', 'G' => '',
                'H' => '消毒剂量', 'I' => '用水量', 'J' => '比例', 'K' => '执行人', 'L' => '监督人', 'M' => '备注'],
            ['A' => '', 'B' => '起', 'C' => '止', 'D' => '', 'E' => '名称', 'F' => '厂家', 'G' => '批号',
                'H' => '', 'I' => '', 'J' => '', 'K' => '', 'L' => '', 'M' => '']
        ];
        $theadStyle = ['font' => '宋体','font-size' => 12,'font-style' => 'bold', 'fill' => '#eee',
            'halign' => 'center', 'border' => 'left,right,top,bottom'];
        $colDataType = ['A' => 'string', 'B' => 'string', 'C' => 'string', 'D' => 'string', 'E' => 'string',
            'F' => 'string', 'G' => 'string', 'H' => 'string', 'I' => 'string', 'J' => 'string',
            'K' => 'string', 'L' => 'string', 'M' => 'string'];
        $colWidth = ['A' => '16', 'B' => '10', 'C' => '10', 'D' => '12', 'E' => '12',
            'F' => '16', 'G' => '16', 'H' => '10', 'I' => '10', 'J' => '10',
            'K' => '10', 'L' => '10', 'M' => '20'];
        $dataRowStyle = ['height' => 20,'font-size' => 12, 'border' => 'left,right,top,bottom'];
        // 工作簿名称
        $sheet1 = 'sheet1';
        $writer->writeSheetHeader($sheet1, array_values($colDataType), ['suppress_row'=>true,
            'widths' => array_values($colWidth)] );
        // 写入表头行
        $writer->writeSheetRow($sheet1, $caption,
            ['height'=>32,'font-size'=>20,'font-style'=>'bold','halign'=>'center','valign'=>'center']);
        // 写入标题行
        $writer->writeSheetRow($sheet1, array_values($thead[0]), $theadStyle);
        $writer->writeSheetRow($sheet1, array_values($thead[1]), $theadStyle);
        // 写入数据
       foreach ($models as $model) {
           $tempRow = [$model->date, substr($model->time_begin, 0, 5), substr($model->time_end, 0, 5),
               $model->area, $model->drug_name, $model->drug_manufacturer, $model->drug_batch_number,
               $model->quantity, $model->ratio, $model->water_used, $model->implementer, $model->supervisor, $model->note];
           $writer->writeSheetRow($sheet1, $tempRow, $dataRowStyle);
           //unset($tempRow);
       }
        //合并单元格,第一行的大标题 和 标题行
        $mergeSettings = ['A1:M1', 'A2:A3', 'B2:C2', 'D2:D3', 'E2:G2', 'H2:H3', 'I2:I3',
            'J2:J3', 'K2:K3', 'L2:L3', 'M2:M3'];
       foreach ($mergeSettings as $mergeSetting) {
           $merge = XLSXWriter::parseMergeRange($mergeSetting) ;
           $writer->markMergedCell($sheet1, $merge['startCellRow'], $merge['startCellColumn'], $merge['endCellRow'], $merge['endCellColumn']);
       }
       //输出文档
        $writer->writeToStdOut();
        exit(0);
    }

    public function actionExcel($ids)  // phpoffice/PhpSpreadsheet 需要 php7.2+!
    {
        $this->layout = false;
        $models = DisinfectRecord::find()->where(['id' => explode(',', $ids)])->all();
        if (count($models) == 0) return;
        // 根据模板文件创建工作簿对象 (模板文件必须存在)
        $spreadsheet = IOFactory::load(User::getFilePath('tpl_disinfect_record.xlsx', '/', true));
        $worksheet = $spreadsheet->getActiveSheet();
        $rowNo = 4;
        foreach ($models as $model) {
            $worksheet->getCell('A' . $rowNo)->setValue($model->date);
            $worksheet->getCell('B' . $rowNo)->setValue($model->time_begin);
            $worksheet->getCell('C' . $rowNo)->setValue($model->time_end);
            $worksheet->getCell('D' . $rowNo)->setValue($model->area);
            $worksheet->getCell('E' . $rowNo)->setValue($model->drug_name);
            $worksheet->getCell('F' . $rowNo)->setValue($model->drug_manufacturer);
            $worksheet->getCell('G' . $rowNo)->setValue($model->drug_batch_number);
            $worksheet->getCell('H' . $rowNo)->setValue($model->quantity);
            $worksheet->getCell('I' . $rowNo)->setValue($model->ratio);
            $worksheet->getCell('J' . $rowNo)->setValue($model->water_used);
            $worksheet->getCell('K' . $rowNo)->setValue($model->implementer);
            $worksheet->getCell('L' . $rowNo)->setValue($model->supervisor);
            $worksheet->getCell('M' . $rowNo)->setValue($model->note);
            ++$rowNo;
        }
        // write
        $writer = new Xls($spreadsheet);
        $writer->setPreCalculateFormulas(false);  // 不要计算公式
        $fileName = rand(1000, 9999).'.xlsx';
        $fullPath = User::getFilePath($fileName, '/', true, '@runtime');
        $writer->save($fullPath);
        // 必须手动解除循环引用并释放内存
        $spreadsheet->disconnectWorksheets();
        unset($spreadsheet);
        // send
        $size = filesize($fullPath);
        $date1 = $models[0]->date;  $date2 = $models[count($models)-1]->date;
        if (strcmp($date1, $date2) > 0) {
            $dateFrom = $date2; $dateTo = $date1;
        } else {
            $dateFrom = $date1; $dateTo = $date2;
        }
        $downloadFileName = '消毒记录#'. $model->address.'#'.$dateFrom.'至'.$dateTo.'.xlsx';
        header("Content-type:application/octet-stream");
        header("Content-Disposition:attachment;filename={$downloadFileName}");
        header("Accept-ranges:bytes");
        header("Accept-length:" . $size);
        echo file_get_contents($fullPath);
        exit;
    }
  • frontend
    我的frontend主要是前台的数据可视化展示。目前没有完全前后端分离,如果彻底分离,那么应该这里只处理非公共的接口(局限与frontend应用的请求)
  • console 我就用2个目录
    • migrations 这里放的是数据库迁移,即 ./yii migrate/create 命令创建的迁移文件
    • controllers 这里可以实现控制台命令,例如我通常建立 DbController 类实现随机化数据表或临时修改表数据
  • api 这部分个人觉得有待参考 yii2作为微框架 实现更合理的 api app
    验证部分,主要是实现一个带验证的基类 AuthActiveController (从 yii\rest\ActiveController 派生)
/**
 * Class AuthActiveController  用查询参数 access_token 作为验证的基类控制器
 * @package api\controllers
 */
class AuthActiveController extends ActiveController
{
    public function behaviors()
    {
        $behaviors = parent::behaviors();
        $behaviors['corsFilter'] = [
            'class' => Cors::class,
            'cors' => [
                'Origin' => ['*'],
                'Access-Control-Request-Method' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
                'Access-Control-Request-Headers' => ['*'],
                'Access-Control-Allow-Origin' => ['*'],         // 允许跨域
                //'Access-Control-Allow-Credentials' => true,     // 允许 cookie 跨域
                'Access-Control-Max-Age' => 86400,
                'Access-Control-Expose-Headers' => [],
            ]
        ];
        //* 为了测试方便可以取消验证
        $behaviors['authenticator'] = [
            'class' => CompositeAuth::class,
            'authMethods' => [
//                [
//                    'class' => HttpBasicAuth::class,
//                    'auth' => function ($username, $password) {
//                        return User::findByUsernamePassword($username, $password);
//                    }
//                ],
                //HttpBearerAuth::class,
                QueryParamAuth::class,
            ]
        ];
        //*/
        return $behaviors;
    }
}

然后其他控制器从它派生,在行为定义中可以取消某些action的验证,例如

class DisinfectRecordController extends AuthActiveController
{
    public $modelClass = 'common\models\DisinfectRecord';

    public function behaviors()
    {
        $behaviors = parent::behaviors();
        $currentAction = Yii::$app->controller->action->id;
        if (in_array($currentAction, ['get-options', 'list', 'fetch'])) {  // 这些 action 取消验证
            unset($behaviors['authenticator']);
        }
        return $behaviors;
    }
    // ........................
}    

除了上面这5个目录,其它子目录和文件为

  • h5 目前uni-app只用了h5,生成的发布放在此处,也可以考虑用软链接
  • log 该目录放nginx 或 apache 日志,个人写日志也可以放在此处
    nginx 网站配置 sites-available/my_app:
server	{
	charset utf-8;
	client_max_body_size 128M;

	listen 80;
	listen [::]:80;

	server_name gxlq_pig;
	
	access_log /home/zime/PhpstormProjects/gxlq_pig/log/access.log;
	error_log /home/zime/PhpstormProjects/gxlq_pig/log/error.log info;
	#	rewrite_log on;  # use rewrite log

	set $front_root /home/zime/PhpstormProjects/gxlq_pig/frontend/web;
	set $back_root /home/zime/PhpstormProjects/gxlq_pig/backend/web;
	set $api_root /home/zime/PhpstormProjects/gxlq_pig/api/web;
	set $h5_root /home/zime/PhpstormProjects/gxlq_pig/h5;
	set $prj_root /home/zime/PhpstormProjects/gxlq_pig;
	root $prj_root;

	location / {
	    root $front_root;

	    try_files $uri index.php?$args  /frontend/web/index.php?$args;

	    location ~ ^\.(js|css|png|jpg|gif|swf|ico|pdf|mov|fla|zip|rar)$ {
	        access_log off;
		expires 360d;

		rewrite ^/(.+)$ /web/$1 break;
		rewrite ^/(.+)/(.+)$ /web/$1/$2 break;
		try_files $uri =404;
	    }
	}

	location /api {
	   rewrite ^(/api)/$ $1 permanent;
	   try_files $uri  /api/web/index.php?$args;
	}

	location ~ ^/api/(.+\.(js|css|png|jpg|gif|swf|ico|pdf|mov|fla|zip|rar))$ {
	    access_log off;
	    expires 360d;

	    rewrite ^/api/(.+)$  /api/web/$1 break;
	    rewrite ^/api/(.+)/(.+)$ /api/web/$1/$2 break;
	    try_files $uri =404;
	}

	location /h5 {
	  rewrite ^(/h5)/$ $1 permanent;
	  try_files $uri /h5/index.html?$args;
	}

	location ~ ^/h5/(.+\.(js|css|ttf|png|jpg|gif|swf|ico|pdf|mov|fla|zip|rar))$ {
	  access_log off;
	  expires 360d;
	}
	 


	location /admin {
	    rewrite ^(/admin)/$ $1 permanent;
	    try_files $uri /backend/web/index.php?$args;
	}

	location ~ ^/admin/(.+\.(js|css|png|jpg|gif|swf|ico|pdf|mov|fla|zip|rar|woff2|svg|eot|woff|ttf))$ {
	    access_log off;
	    expires 360d;

	    rewrite ^/admin/(.+)$  /backend/web/$1 break;
	    rewrite ^/admin/(.+)/(.+)$ /backend/web/$1/$2 break;
	    try_files $uri =404;
	}

	

	# deny accessing php files for the /assets directory
	location ~ ^/assets/.*\.php$ {
	    deny all;
	}    

	# pass PHP scripts to FastCGI server
	location ~ \.php$ {
		fastcgi_pass unix:/var/run/php/php7.2-fpm.sock;
	#	fastcgi_pass 127.0.0.1:9000;
		fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
		include  fastcgi_params;
		try_files $uri =404;
	}

	location = /requirements.php {
	    deny all;
	}

	# deny access to .htaccess files, if Apache's document root
	location ~ /\.(ht|svn|git) {
		deny all;
	}
}
  • vendor 这个目录没什么好说的
  • 根目录文件
    • yiiyii.bat 这是 yii 命令文件
    • composer.jsoncomposer.lock 这2个文件不解释,没什么好解释的
    • clean.php 这个文件主要是清理用的,通常是备份或打包存档之前先清理
      主要是需要删除一些文件,文件代码类似如下:

// 清理文件以便备份
$isLinux = (PHP_OS == 'Linux');

function recursiveRemoveDir($dir) { // $dir itself not deleted
    $h = opendir($dir);
    while (false !== $filename = readdir($h)) {
        if ($filename == '.' || $filename == '..') continue;
        if (is_dir($dir.'/'.$filename)) {
            recursiveRemoveDir($dir.'/'.$filename);
            rmdir($dir.'/'.$filename);
        } else {
            unlink($dir.'/'.$filename);
        }
    }
    closedir($h);
}

if (file_exists(__DIR__.'/h5.zip'))  unlink(__DIR__.'/h5.zip');
recursiveRemoveDir(__DIR__.'/log');

recursiveRemoveDir(__DIR__.'/api/runtime');
recursiveRemoveDir(__DIR__.'/backend/runtime');
recursiveRemoveDir(__DIR__.'/console/runtime');
recursiveRemoveDir(__DIR__.'/frontend/runtime');

recursiveRemoveDir(__DIR__.'/backend/web/assets');
recursiveRemoveDir(__DIR__.'/frontend/web/assets');
    • (readme.md)
    • .gitignore 文件内容见后 git设置
    • (.htaccess) 路由重写时nginx也可能用到此文件,所以需要对云机h5目录放入以下 .htaccess 文件
\# use mod_rewrite for pretty URL support
RewriteEngine on
#if a directory or a file exists, use the request directly
RewriteCond %{REQUEST_FILENAME}  !-f
RewriteCond %{REQUEST_FILENAME}  !-d
\# otherwise forward the request to index.html
RewriteRule  .  index.html
    每个应用 web 目录根下放的 **.htaccess** 文件内容为
# use mod_rewrite for pretty URL support
RewriteEngine on
#if a directory or a file exists, use the request directly
RewriteCond %{REQUEST_FILENAME}  !-f
RewriteCond %{REQUEST_FILENAME}  !-d
# otherwise forward the request to index.php
RewriteRule  .  index.php

git设置

根目录 .gitignore 文件信息

# log file
log/
h5/
h5.zip

# old or backup file
*.bak
*.old

# Phpstorm
.idea
/idea/
*.ipr
*.iml
*.iws

# test file
*-test.php

# directory runtime and web/assets
api/runtime/
backend/runtime/
console/runtime/
frontend/runtime/

backend/web/assets/
frontend/web/assets/

# vendor
vendor/

# tests
tests/
api/httpRestTest/
/common/fixtures/

# upload
/backend/web/help.pdf
/backend/web/upload/

# my template
/sjgCodeTemplate/

uni-app 的一些使用要点

虽然对于HBuilderX的一些功能还不是让人特别满意,尤其是自动完成中引号和换行时末尾自动插入引号实在太坑,自动格式化也不理想,还没有集成git,也没有linux版本,不过它是uni-app的官方开发环境,最大程度提供了对uni-app的支持,安装插件之类比较方便。

目录结构

  • package.jsonpackage-lock.json 我的app作为一个package的说明,特别是依赖情况,我使用了用来图像转换压缩的组件 image-conversion,所以,dependencies部分会多一点。这个组件用 npm 或 yarn 安装
npm i image-conversion --save
# or 
yarn add image-conversion

package.json、package-lock.json会被修改,node_modules目录下会多一个子目录 image-conversion

  • node_modules node组件目录,例如上文中的image-conversion
  • components 这个目录下是uni-app组件
  • uni_modules 这个目录下是符合easy_com规范的uni-app组件(一般使用uni-app组件包括3步,即安装到上面的components目录,vue组件开头部分外部用import引入,vue组件内components部分注册,而easy_com规范的组件只要安装了可以直接使用,省去了后面2步)
  • manifest.json 这是应用的发布配置文件,我们用了基础配置(应用名称等)和h5配置(用了运行的基础路径,我是 /h5/. ,即发布的东西是放入URL基础目录为/h5/的下面的
  • common
    • config.js 这个文件放了项目的配置信息(供代码使用的公共配置)
export default {
	webUrl: 'http://192.168.91.151/api',  //   '/api',  //  
	imageBaseUrl: 'http://192.168.91.151/admin/upload',  // '/../admin/upload',  //  
	imageTypes: {
		'png': 'image/png', 
		'jpg': 'image/jpeg',
		'jpeg': 'image/jpeg',
		'gif': 'image/gif'
	}
}
  • pages.json 每个页面和下部的tabBar都必须在这个文件注册,如 页面路径 pages/disinfect/add
  • uni.scss 这是uni-app内置的常用样式变量,主要是颜色和基本尺寸之类,我们可以使用这些变量,不需要import这个文件,但样式需要用scss格式
  • static 这个目录下放入图片、字体等静态资源
  • store
    • index.js 我们用vuex实现的store,store实例主要包括state和mutations,实现状态统一管理
  • utils 这里可以放一些实用工具函数或通用错误处理函数
    • Utils.js export default { 一些公共函数 },例如,我放入了生成图片Url的函数
    makeImageUrl(src, imageBaseUrl) {
		if (Array.isArray(src))  src = src[0]   // for blob:  [path]
		return src.indexOf('blob:') > -1 ? src : imageBaseUrl + '/' + src
	}
  • App.vue (主入口根组件一般不用修改),main.js 启动主入口文件,import方式导入一些东西, Vue.prototype.$something = something 方式把something注册为所有vue实例共享的属性或方法(从而所有vue实例都可以this.$something方式使用,相当于这种方式引入的属性或方法是”类静态“的,而不是实例的,它对vue来说是全局的,但不是JavaScript全局的),最后
const app = new Vue({
	store,
	config,
	...App
})
app.$mount()
  • pages 基本上开发都是在这个目录里,一般一个子目录对应一个功能模块,子目录里的每个vue文件对应一个或几个相关的case,例如,我的 pages/disinfect/add.vuepages/disinfect/list.vue
  • unpackage 编译生成的发布文件会在这个目录下

store中的state和相关mutation

store中的state相当于中心控制的一些变量,要修改它必须通过相应的mutation而不是直接赋值。其大致结构是

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
	state: {
		token: '',
		userInfo: {},
		// .........
	},
	mutations: {
		UPDATE_TOKEN(state, token) {
			token ? (state.token = token) : (state.token = '')
			uni.setStorageSync('USER_TOKEN', token)
		},
		UPDATE_USER_INFO(state, user) {
			user ? (state.userInfo = user) : (state.userInfo = {})
			uni.setStorageSync('USER_INFO', user)
		},
		// .......
		GET_APP_STORAGE(state) {
			const token = uni.getStorageSync('USER_TOKEN')
			token ? state.token = token : ''
			const userInfo = uni.getStorageSync('USER_INFO')
			userInfo ? state.userInfo = userInfo : {}
			// ............
		},
		USER_LOGOUT(state) {
			state.token = ''
			state.userInfo = {}
			// .............
			uni.removeStorageSync('USER_TOKEN')
			uni.removeStorageSync('USER_INFO')
			//  ............
		}
	}
})

export default store	

上面的代码中,UPDATE_XXX 是修改了state中的变量,同时将变量值写入localstorage,GET_APP_STORAGE则总是从localstorage读取出保存的变量值来恢复state变量值,USER_LOGOUT是注销时需要做的清理,既包括变量值恢复成初始状态,还包括从localstorage删除信息

组件中使用store状态变量

外部开头需要import两个映射组件(不用这种映射方式会非常麻烦)

	import {
		mapState,
		mapMutations
	} from 'vuex'

把需要用的store状态变量映射成我们组件的计算属性,把需要用的mutation方法映射成组件的方法

	export default {
		computed: {
			...mapState(['token', 'userInfo', 'companyId'])
		},
		data() {
			// .......
		},
		// .......
		methods: {
			...mapMutations(['UPDATE_PROGRESS_ZC', 'GET_APP_STORAGE']),	
			// .........	

然后,可以在组件的其他地方像普通属性和方法一样使用状态变量和mutation(有点”注入“的意思),读取属性前,最好总是调用 this.GET_APP_STORAGE(),因为从localstorage恢复出来的值才是可靠的

照片的选择、压缩、取消选择/删除、预览

压缩我用了 image-conversion ,所以在外部

	import {compress, compressAccurately} from 'image-conversion'

照片处理主要使用uni-app的媒体处理中的图片,可参考https://uniapp.dcloud.io/api/media/image。
和照片上传有关的属性(我允许用户选择压缩后的大小,所以额外多了选择列表和索引)

		data() {
			return {
				// .............
				imageBaseUrl: '',    //  onload时从配置中读取这项信息并写入
				images: [],          //  照片 url 的服务器实际储存情况
				imagesTemp: [],      //  照片 url 的当前情况(临时列表),onload时用 Object.assign 从 images 深拷贝
				sourceTypeIndex: 2,
				sourceType: ['拍照', '相册', '拍照或相册'],
				sizeTypeIndex: 2,
				sizeType: ['压缩', '原图', '压缩或原图'],
				countIndex: 3,
				count: [1, 2, 3, 4],
				imageSizeLimits: [50000, 4000, 2000, 1000, 800, 500, 200, 120, 80],
				islIndex: 3,       // 默认 第4项 1000KB
				extensionDict: {}
			}
		},

和图像相关的模板代码类似如下

				<view class="proof">
					<view v-if="imagesTemp" class="images" style="display: flex;">
						<view v-for="(image, index) in imagesTemp" :key="index">
							<view style="position: relative;">
								<image class="image" :src="(images.indexOf(image) > -1 ? imageBaseUrl + '/' : '') + image"
								 :data-src="image" @tap="previewImage">image>
								<image v-if="true" class="image-x" src="../../static/icon/remove.png" @click="remove(index)">image>
							view>
						view>
						<image v-if="imagesTemp.length < 4" class="image" src="../../static/icon/save.png" @click="chooseImage">image>
					view>
				view>

删除/取消选择图片的代码 remove(index) 比较简单,就是 uni.showModal() 显示模态对话框,在确定的时候用 _self.imagesTemp.splice(index, 1) 从临时列表删掉即可。
预览图片的代码也比较简单

			previewImage: function(e) {
				let src = e.target.dataset.src 
				var current =  _self.$util.makeImageUrl(src, _self.imageBaseUrl)
				uni.previewImage({
					current: current,
					urls: _self.imagesTemp.map(v => {
						return _self.$util.makeImageUrl(v, _self.imageBaseUrl)
					}) 
				})
			},

选择图片的代码比较麻烦,主要是要压缩(压缩组件似乎有点小bug,对png格式支持有问题,所以这里用了jpeg,而不管原来照片是什么格式)

            chooseImage: function() {
				uni.chooseImage({
					sourceType: sourceType[this.sourceTypeIndex],
					sizeType: sizeType[this.sizeTypeIndex],
					count: this.imagesTemp.length + this.count[this.countIndex] > 4 ? 4 - this.imagesTemp.length
						: this.count[this.countIndex],  // 似乎只需要 4 - this.imagesTemp.length 表示剩余还可以选择几张
					success: (res) => {
						let file = res.tempFiles[0]
						if (file.size > _self.imageSizeLimits[_self.islIndex] * 1000) {
							let extension = file.name.substr(1+file.name.lastIndexOf('.')).toLowerCase()
							let compressConfig = {
								size: _self.imageSizeLimits[_self.islIndex],
								accuracy: 0.9,
								type: 'image/jpeg'
							}
							if (file.size > 8*1024*1024) compressConfig['width'] = 1920
							compressAccurately(file, compressConfig).then(res => {
								let blobUrl = URL.createObjectURL(res)
								this.imagesTemp.push(blobUrl)
								this.extensionDict[blobUrl] = 'jpg' // extension  // 压缩后上传丢失后缀
							})
						} else {  // 不压缩的原图,也可以用 file.path = res.tempFilePaths[0] 获取 blob URL
							this.imagesTemp = this.imagesTemp.concat([res.tempFilePaths]);
						}
					},
					fail: (err) => {}
				})
			},

表单提交与照片上传

预览时看到的照片,既可能是远程服务器本来就有的url,也可能是本地刚刚选择还上传的,所以,文件路径要分别处理

                let filePaths = []
				for (let v of _self.imagesTemp) {
					if (Array.isArray(v)) filePaths.push(v[0])
					else if (v.indexOf('blob:') > -1) filePaths.push(v)
				}

还要计算原来远程服务器存在的照片,然后准备删除的 (相当于 images 和 imagesTemp 的差集)

				let setTemp = new Set(_self.imagesTemp)
				let filesToDelete = _self.images.filter(v => !setTemp.has(v)) // 原来存在,现在需要删除的图片

照片通常比较大,上传失败的可能远高于表单数据的提交,所以,我先用 uni.showLoading() 显示正在上传,然后打包所有上传任务为 Promise 对象数组,确保上传后再保存表单

				uni.showLoading({
					title: '正在上传……'
				})
				let promises = []  // 用于打包所有上传任务,以确保上传任务在保存表单之前完成
				for (let filePath of filePaths) {
					promises.push(_self.$http.upload('biosafety-check-zc/upload-score', {
						params: {
							'access-token': _self.token  //, 'XDEBUG_SESSION_START': 'PHPSTORM'
						},
						name: 'dirty_images_paper_zc', // 后台需要这个name,也对应后台 category
						filePath: filePath,
						formData: {   // 随上传 POST 提交的数据
							key: _self.token,
							order: _self.id,
							extension: (filePath in _self.extensionDict) ? _self.extensionDict[filePath] : ''
						}
					}).catch(err => {
						console.log(err)
					}))
				}
				Promise.all(promises).then(() => {
					uni.hideLoading()    // 手动关闭加载模态对话框
					_self.$http.post('biosafety-check-zc/save-score', {
						key: _self.token,
						category: 'dirty_images_paper_zc',
						order: _self.id,
						id: _self.id,
						filesToDelete: filesToDelete,
						cachedNum: filePaths.length,      // 后台缓存的文件数目应该和已经上传的文件数目相等
						score: _self.score
					}, {
						params: {
							'access-token': _self.token  //, 'XDEBUG_SESSION_START': 'PHPSTORM'
						}
					}).then(res => {
						if (res.data.saveOk) {  // 保存成功,重新获取数据,把进度清空
							uni.showToast({
								title: res.data.cachedNum == filePaths.length ? '保存成功' : '上传文件不完整',
								duration: 2000,
								success() {
									setTimeout(function() {}, 2000);
								}
							})
							// 重新获取数据
							_self.$http.get('/biosafety-check-zc/view', {
								params: {
									'access-token': _self.token,
									'id': _self.id
								}
							}).then(res => {
								_self.images = res.data.urls_paper_table ? res.data.urls_paper_table.split('|') : [],
								Object.assign(_self.imagesTemp, _self.images) // deep copy
							}).catch(err => {
								console.log(err)
							})
							_self.UPDATE_PROGRESS_ZC(null)  // clear progress
						} else {
							uni.showToast({
								title: '保存失败',
								duration: 2000,
								success() {
									setTimeout(function() {}, 2000);
								}
							})
						}
					}).catch(err => {
						console.log(err)
					})
				}).catch(err => {
					uni.hideLoading()   // 上传出错也需要手动关闭加载模态对话框
					console.log(err)
				})

上传文件用了插件 luch-request ,安装后根目录会多一个 js_sdk/luch-request,在主入口 main.js 中 import Request from '@/js_sdk/luch-request/luch-request' 引入,

const http = (baseUrl) => {
	return new Request({
		baseURL: baseUrl,
		header: {
			'Access-Control-Allow-Origin': '*'
		}
	})
}
Vue.prototype.$http = http(config.webUrl)

上面的代码自己包装一下,这样所有组件都可以 this.$http.get()/post()/upload() 了

后台处理上传、暂存、表单保存

上传的关键是那个name,根据name生成file对象,可以POST一定信息,随上传提交,因为多个文件上传,我们是将有关信息暂存到lookup表,在表单保存时检查是否所有上传都是成功的,不是所有上传都成功,就维持原来的图片信息(不删除原来的,删除暂存的),类似代码如下

    public function actionUploadScore()
    {
        $posted = Yii::$app->request->post();
        $file = UploadedFile::getInstanceByName('dirty_images_paper_zc');
        if (empty($file) || empty($posted)) return ['uploadOk' => false];  // 上传失败,未能生成文件对象
        $fileName = 'bsc_zc_'.date('YmdHis'). '_' . rand(1000, 9999) . '.' .
            ($posted['extension'] ? $posted['extension'] : $file->extension);
        $fullPath = User::getFilePath($fileName, 'upload/', true, '@backend/web');
        $uploadOk = $file->saveAs($fullPath);  // 保存文件到磁盘
        if ($uploadOk) {
            (new Lookup([
                'key' => $posted['key'],  // token
                'category' => 'dirty_images_paper_zc',
                'item' => $fileName,
                'order' => $posted['order']  // record_id
            ]))->insert(false);    // 登记到 Lookup
        }
        return ['uploadOk' => $uploadOk];
    }

    public function actionSaveScore()
    {
        $posted = Yii::$app->request->post();
        $filesToDelete = $posted['filesToDelete'];
        $model = BiosafetyCheckZc::findOne($posted['id']);
        $pics = $model->urls_paper_table ? explode('|', $model->urls_paper_table) : []; // 原有图片
        if (!empty($filesToDelete)) {
            $pics = array_diff($pics, $filesToDelete);  // 去掉要删除的图片
        }
        $cachedItems = Lookup::findAll(['key' => $posted['key'], 'category' => $posted['category'],
            'order' => $posted['order']]);
        foreach ($cachedItems as $cachedItem) {
            $pics[] = $cachedItem->item;  // 添加暂存的已上传图片
        }
        $model->urls_paper_table = implode('|', $pics);
        $model->score = $posted['score'];
        $saveOk = false;
        if ($posted['cachedNum'] == count($cachedItems)) {  // 所有图片上传成功且暂存成功才保存表单
            $saveOk = $model->save();
            if ($saveOk == false) {
                foreach ($cachedItems as $cachedItem)
                    @unlink(User::getFilePath($cachedItem->item, 'upload/', true, '@backend/web'));
            } else {
                foreach ($filesToDelete as $fileToDelete)
                    @unlink(User::getFilePath($fileToDelete, 'upload/', true, '@backend/web'));
            }
        }
        Lookup::deleteAll(['key' => $posted['key'], 'category' => $posted['category'],
            'order' => $posted['order']]); // 清理掉暂存信息
        return ['score' => $model->score, 'saveOk' => $saveOk, 'cachedNum' => count($cachedItems)];
    }

elementUI2 CDN用法要点

目前我只有少量局部使用elementUI2,所以使用了 CDN 方式 的 element ,即 link标签引入element样式index.css,script标签引入 vue.js 和 element 的 index.js,如果使用 axios,可以使用script标签引入 CDN 方式的axios

对 element 我才刚刚看了一点点,这部分内容有待补充

你可能感兴趣的:(yii2+elementUI2+uni-app使用小结)