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文本,包含若干个 <img /> 标签
*/
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 : '(无)<br>';
}
/**
* 从上传目录删除指定文件名的脏文件
* @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/ break;
rewrite ^/(.+)/(.+)$ /web// break;
try_files $uri =404;
}
}
location /api {
rewrite ^(/api)/$ 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/ break;
rewrite ^/api/(.+)/(.+)$ /api/web// break;
try_files $uri =404;
}
location /h5 {
rewrite ^(/h5)/$ 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)/$ 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/ break;
rewrite ^/admin/(.+)/(.+)$ /backend/web// 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 这个目录没什么好说的
- 根目录文件
- yii 、yii.bat 这是 yii 命令文件
- composer.json、composer.lock 这2个文件不解释,没什么好解释的
- clean.php 这个文件主要是清理用的,通常是备份或打包存档之前先清理
主要是需要删除一些文件,文件代码类似如下:
<?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.json、package-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.vue
和pages/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 我才刚刚看了一点点,这部分内容有待补充