2015年1月14日 星期三

用 jQuery EasyUI 打造輕量級 CMS (八)

這幾天主要在寫檔案上傳下載功能, 本以為很簡單, 只要套用應用程式上傳的程式碼即可快速解決, 沒想到弄了兩三天還沒搞定, 而且還發現之前寫的檔案函式庫 file.php 中的上傳函式有 bug.

寫檔案上傳下載參考了下列文章, 特別是下載部分, 一般的 PHP 書籍探討下載的較少, 我主要是參考 "php 檔案下載" 這篇, 採用 http 標頭來傳送檔案.

# Download a file by jQuery.Ajax
透過Javascript觸發檔案下載
# php 檔案下載
# PHP 如何取得 現在檔案的目錄 與 上層目錄
# Download File Using jQuery
# jQuery File Download Plugin for Ajax
# ASP.Net : Download file using jQuery
# jquery.fileDownload.js & jQuery UI Dialog

這陣子系統規劃已做了大幅度改變, 來不及紀錄, 因此只好就現況說明, 首先必須在系統安裝檔 install.php 增加一個 sys_files 來記錄已上傳的檔案資料 :

    //建立 sys_files 資料表
    $data_array["id"]="smallint(6) NOT NULL AUTO_INCREMENT PRIMARY KEY";
    $data_array["file_name"]="varchar(255)";          //檔案名稱
    $data_array["file_note"]="varchar(255)";          //檔案說明
    $data_array["file_type"]="varchar(255)";          //檔案類型
    $data_array["file_size"]="varchar(255)";          //檔案大小
    $data_array["uploader"]="varchar(255)";           //上傳者
    $data_array["upload_time"]="datetime";            //上傳時間
    $data_array["download_count"]="int(10) unsigned"; //下載次數
    $data_array["public"]="char(1)";                  //公開 "Y"/"N"
    $result=create_table("sys_files",$data_array);
    if ($result) {$msg .= "建立資料表 sys_files ... 完成!<br>";}

其中 file_name, file_type, file_size 用來儲存上傳函式的傳回值, public 用來讓上傳者選擇是否讓所上傳的檔案可被其他人下載. download_count 用來統計下載次數, 每次被下載時, 此欄之值就加 1, 因此它須賦予初值 0.

然後在系統功能檔 sys.php 裡增添一個 files 的 case :

  case "files" : {
?>

<?php
      break;
      }

我們的檔案列表用 datagrid 來顯示, 就放在上面的 ?> 與 <?php 中間 :

<div class="tab" title="檔案">
  <!--檔案 sys_files 列表-->
  <table id="sys_files" title="檔案列表" style="width:auto" data-options="tools:'#files_tools',toolbar:'#files_toolbar'"></table>
  <div id="files_tools">
    <a href="#" id="upload_file" class="icon-add" title="上傳檔案"></a>
    <a href="#" id="remove_file" class="icon-remove" title="刪除檔案"></a>
    <a href="#" id="reload_files" class="icon-reload" title="重新載入"></a>
    <a href="#" id="download_file" class="icon-save" title="下載另存"></a>
  </div>
  <div id="files_toolbar" style="text-align:right;padding:2px;">
    <select id="files_search_field" class="easyui-combobox" data-options="panelHeight:120">
      <option value="file_name">檔案名稱</option>
      <option value="file_note">檔案說明</option>
      <option value="file_type">檔案類型</option>
      <option value="uploader">上傳者</option>
      <option value="upload_time">上傳時間</option>
    </select>
    <input id="files_search_what" class="easyui-textbox">
  </div>

上面的 HTML 主要就是在頁籤裡頭放一個 id="sys_files" 的 datagrid, 它具有一個工具 (tools) 以及一個工具按鈕列 (toolbars), 前者用來放置控制檔案操作的小圖示 (上傳, 刪除, 下載, 更新); 後者用來放置搜尋框.

當然這個 datagrid 需要 JS 來初始化 :

    //檔案 sys_files
    $('#sys_files').datagrid({
      columns:[[
        {field:'id',title:'id',sortable:true},
        {field:'file_name',title:'檔案名稱',sortable:true},
        {field:'file_note',title:'檔案說明',sortable:true},
        {field:'file_type',title:'檔案類型',sortable:true},
        {field:'file_size',title:'檔案大小',align:'right',sortable:true},
        {field:'uploader',title:'上傳者',sortable:true},
        {field:'upload_time',title:'上傳時間',sortable:true},
        {field:'download_count',title:'下載次數',align:'right',sortable:true}
        ]],
      url:"sys.php",
      queryParams:{op:"list_files"},
      fitColumns:true,
      singleSelect:true,
      pagination:true,
      pageSize:10,
      rownumbers:true,
      onDblClickRow:function(index,row){
        var url="sys.php?op=download_file&id=" + row.id +
                "&file_name=" + row.file_name;
        window.open(url);
        }
      });
    $("#files_search_what").textbox({
      icons:[{
        iconCls:"icon-search",
        handler:function(e){
          $('#sys_files').datagrid("load",{
            op:"list_files",
            search_field:$("#files_search_field").combobox("getValue"),
            search_what:$("#files_search_what").textbox("getValue")
            });        
          }
        }]
      });

這裡很重要的一點是, url 與 queryParams 這兩個參數指定了 datagrid 的資料來源, 所有架站系統的功能我都整合在 sys.php 這個檔案裡 (雖然專家不建議這麼做, 好像難維護, 但我討厭一大堆零零散散的小檔案), 然後用 op 參數與 switch-case 指令來選擇要執行的功能. 所以要在 sys.php 裡增添一個 case=list_files 的模組來輸出 datagrid 所需的 json :

  case "list_files" : {
    $page=isset($_REQUEST['page']) ? intval($_REQUEST['page']) : 1;
    $rows=isset($_REQUEST['rows']) ? intval($_REQUEST['rows']) : 10;
    $sort=isset($_REQUEST['sort']) ? $_REQUEST['sort'] : 'upload_time';
    $order=isset($_REQUEST['order']) ? $_REQUEST['order'] : 'desc';
    if (isset($_REQUEST['search_field'])) { //有 search
      if ($_SESSION["user_level"]==9) { //管理員 (搜尋全部)
        $where="WHERE ".$_REQUEST['search_field']." LIKE '%".
               $_REQUEST['search_what']."%'";
        }
      else { //非管理員 (搜尋公開的與自己上傳的)
        $where="WHERE (public='Y' OR uploader='".$_SESSION["user_name"].
               "') AND ".$_REQUEST['search_field']." LIKE '%".
               $_REQUEST['search_what']."%'";
        }
      }
    else { //無 search (顯示全部)
      if ($_SESSION["user_level"]==9) {$where="";} //管理員
      else { //非管理員 (只能看得到公開的與自己上傳的)
        $where="WHERE public='Y' OR uploader='".$_SESSION["user_name"]."'";
        }          
      }
    $start=($page-1) * $rows;  //本頁第一個列索引 (0 起始)
    $SQL="SELECT COUNT(*) FROM `sys_files`";
    $RS=run_sql($SQL);
    $total=$RS[0][0]; //紀錄總筆數
    $SQL="SELECT * FROM sys_files ".$where." ORDER BY ".
         $sort." ".$order." LIMIT ".$start.",".$rows;
    $RS=run_sql($SQL);
    $files=Array();
    if (is_array($RS)) {
      for ($i=0; $i<count($RS); $i++) {
        $files[$i]=Array("id" => $RS[$i]["id"],
                         "file_name" => $RS[$i]["file_name"],
                         "file_note" => $RS[$i]["file_note"],
                         "file_type" => $RS[$i]["file_type"],
                         "file_size" => $RS[$i]["file_size"],
                         "uploader" => $RS[$i]["uploader"],
                         "upload_time" => $RS[$i]["upload_time"],
                         "download_count" => $RS[$i]["download_count"]
                         );
        }
      }
    $arr=array("total" => $total, "rows" => $files);
    echo json_encode($arr);
    break;
    }

上面預設排序是依上傳時間 upload_time 倒序排列, 亦即最新上傳的會列在最上面. 管理員可以看到所有上傳的檔案列表, 而一般使用者只能看到自己上傳的檔案以及別人公開的檔案, 公開與否由 public 這欄位來決定, 上傳時若未指明, 預設為公開.

其次, 我們替 datagrid 指定了 Double Click 事件, 當用戶雙擊任何一列時就會觸發此事件, 並傳回其索引 index 與列物件 row, 透過 row.id 與 row.file_name 即可取得要下載的檔案的 id 與名稱, 組成 url 後開啟一個新視窗來進行下載.


接著是處理檔案上傳按鈕圖示 (id="upload_file") 被按下時的情況, 這時要顯示一個 dialog 交談框, 讓使用者上傳檔案, 所以在上面的 HTML 後面添加 :

  <!--上傳檔案表單對話框-->
  <div id="upload_file_dialog" class="easyui-dialog" title="上傳應用程式" style="width:420px;height:225px;"  data-options="closed:'true',buttons:'#upload_file_buttons'">
    <form id="upload_file_form" style="padding:15px" action="sys.php?op=upload_file" method="post" enctype="multipart/form-data" target="upload_target">
      <div style="margin:10px">
        <label style="width:60px;display:inline-block;">檔案 : </label>
        <input name="upload" class="easyui-filebox" data-options="buttonText:'選擇檔案',prompt:'',required:true" style="width:280px">
      </div>
      <div style="margin:10px">
        <label style="width:60px;display:inline-block;">說明 : </label>
        <input name="file_note" class="easyui-textbox" style="width:280px" required="true">
      </div>
      <div style="margin:10px">
        <label style="width:60px;display:inline-block;">公開 : </label>
        <select name="public" class="easyui-combobox" panelHeight="50"  style="width:80px">
          <option value="Y">Y</option>
          <option value="N">N</option>
        </select>
        <img src="images/uploading.gif" id="uploading" style="visibility:hidden;margin-left:20px;">
      </div>
      <div>
        <iframe id="upload_target" name="upload_target" src="#"           style="width:0;height:0;border:0px solid #ffffff;">
        </iframe>
      </div>
    </form>
  </div>
  <div id="upload_file_buttons" style="padding-right:15px;">
    <a href="#" id="clear_file" class="easyui-linkbutton" iconCls="icon-clear" style="width:90px">重設</a>
    <a href="#" id="upload_file_go" class="easyui-linkbutton" iconCls="icon-ok" style="width:90px">上傳</a>
  </div>
</div>

其事件處理的 JS 如下 :

    $("#clear_file").bind("click",function(){
      $("#upload_file_form")[0].reset();
      });
    $("#upload_file_go").bind("click",function(){
      $("#uploading").css("visibility","visible");
      $("#upload_file_form").form("submit");
      });
    $("#upload_file").bind("click",function(){
      $("#upload_file_dialog").dialog("open").dialog("setTitle","上傳檔案");
      $("#upload_file_form").form("clear");
      });


這個 dialog 對話框預設不可以顯示, 因此在 data-options 中要將 closed 屬性設為 true, 必須等按下右上角的 upload_file 按鈕時才呼叫 open 方法將其開啟. 這 dialog 內放了一個表單, 注意, 其編碼型態 enctype 須設為 multipart/form-data 以便傳送檔案. 而後端程式執行結果的輸出目的 target 設為 upload_target, 這是 dialog 對話框中一個看不到 (大小為 0) 的 iframe 的 id, 這 iframe 是作為一個前後端的中介站, 用來執行上傳完畢時要顯示的訊息框程式. 因為檔案上傳後, 前端網頁程式並不知道後端何時會完成收檔, 因此也無法適時跳出一個訊息框來顯示上傳完成之訊息, 因此我們偷偷在前端網頁設置一個看不到的 iframe 當窗口, 當後端完成檔案接收後, 就輸出一個顯示傳檔已完成的 JS 程式丟回這個 iframe 中執行來執行, 讓使用者誤以為我們前端網頁能預測傳檔何時能完成呢, 真是巧妙啊.

顯示上傳結果的 JS 如下之 upload_callback() 所示, 它首先會將顯示上傳中的 gif 動畫圖檔還原為隱藏狀態, 然後關閉上傳對話框, 重新載入 datagrid, 最後顯示後端傳回來的上傳結果 (參數 status 與 msg) :

    <!--Top Level Script -->
    function upload_callback(status,msg) { //必須在最上層, 不可放在 $ 內
      $("#uploading").css("visibility","hidden");
      $("#upload_file_dialog").dialog("close");
      $("#sys_files").datagrid("reload",{op:"list_files"});
      if (status=="success") {var msg=msg + '檔案上傳成功! <br>';}
      else {var msg=msg + '檔案上傳失敗, 請重新上傳! <br>';}
      $.messager.alert("上傳結果", msg);
      }

在此 dialog 中用來指定要上傳之檔案的元件是 id=upload 的 easyui-filebox 元件, 按下其選擇檔案按鈕會跳出作業系統的檔案選擇對話框, 當選定檔案後, 其文字欄位會顯示檔案名稱, 但路徑一律顯示 C:\fakepath. 按下上傳鈕會向後端提交表單, 這時對話框中會冒出一個 uploading 的動畫圖形, 顯示還在上傳中, 這是因為我們在對話框中偷偷放了一個預設看不見 (visibility:none) 的 uploading.gif 圖檔, 這是我在網路上蒐來的, 因為 EasyUI 本身沒有提供, 我把它放在根目錄的 images 下. 當檔案接收完成, 必須再度將此圖檔隱藏起來.

所以在 sys.php 中須增添一個 case=upload_file 來處理上傳檔案之接收 :

  case "upload_file" : {
    $status="success";       //預設上傳成功
    $msg="";                     //執行結果字串
    $upload_dir="./files/";  //設定上傳目錄
    $file_note=$_REQUEST["file_note"];     //取得檔案說明
    $public=empty($_REQUEST["public"])?"Y":$_REQUEST["public"]; //取得公開與否 (預設 Y)
    $result=upload_file("upload", $upload_dir);
    if ($result !== FALSE) {
      $msg .= $result["name"]." (".$result["size"]." bytes).<br>";
      $data_array["file_name"]=$result["name"];
      $data_array["file_note"]=$file_note;
      $data_array["file_type"]=$result["type"];
      $data_array["file_size"]=$result["size"];
      $data_array["uploader"]=$_SESSION["user_name"];
      $data_array["upload_time"]=date("Y-m-d H:i:s");
      $data_array["download_count"]=0;
      $data_array["public"]=$public;
      insert("sys_files", $data_array);
      } //end of if
    else { //上傳失敗
      $msg .= $file_name." 上傳失敗 (".$result["file_error"].")!<br>";
      $status="failure";
      } //end of else
    $para='"'.$status.'","'.$msg.'"'; //回傳回呼函式之參數
?>
<!-- 輸出 script 到 $op=files 中的 iframe 並執行其中的回呼函式 -->
<script language="javascript" type="text/javascript">
  window.top.window.upload_callback(<?php echo $para; ?>);
</script>
<?php
    break;
    }

上面這個 JS 是就是檔案接收程式最後的輸出, 目的地就是前端網頁表單的 target 所指的 iframe 元素, 它會呼叫我們放在前端網頁視窗最上層的函式 upload_callback(), 其實就是把參數傳進去顯示上傳結果而已.

這個檔案接收程式使用了一個自建的函式 upload_file(), 我把它寫在 /lib/ 下的 file.php 中, 因此必須載入此函式庫才能使用 :

/*-----------------------------------------------------------------------------
upload_file($uploader,$path="./")
功能 :
  此函數將上傳之單一檔案, 由暫存區移至指定路徑下.
參數 :                                
  $uploader : 上傳元件名稱, 即 <input type="file" name="uploader"> 中之 name 值
  $path     : 檔案儲存路徑, 例如 "./images"
傳回值 :
  成功傳回陣列, 失敗傳回 FALSE. 陣列有三個元素 :
  $result["name"]=檔案名稱
  $result["type"]=檔案類型
  $result["size"]=檔案大小
範例 :
  <input type="file" name="uploader">
  $result=upload_file("uploader", "./images");
  echo "檔案 ".$result["name"]." 上傳成功 (".$result["size"]." bytes)";
-----------------------------------------------------------------------------*/
function upload_file($uploader,$path="./") {
  if ($_FILES[$uploader]["error"]==0) { //上傳成功
      $tmp_name=$_FILES[$uploader]["tmp_name"];
      $new_name=$path.$_FILES[$uploader]["name"];
      if (move_uploaded_file($tmp_name, $new_name)) { //移動成功
          $result["name"]=$_FILES[$uploader]["name"];
          $result["type"]=$_FILES[$uploader]["type"];
          $result["size"]=$_FILES[$uploader]["size"];
          } //end of if
      else {$result=FALSE;} //移動失敗
      } //end of if
  else {$result=FALSE;} //上傳失敗
  return $result;
  }

此函式只要傳入兩個參數, 第一參數 uploader 就是前端上傳元件 input 的 name, 這裡就是 "upload", 第二參數是上傳目錄, 這裡要傳入 "./files/". 如果上傳成功, 它會傳回一個關聯式陣列, 其元素分別是 name (檔案名稱), type (檔案類型), 與 size (檔案大小). 若上傳失敗就傳回 false.

2015-01-26 補充 :

今天回頭測試下載功能, 發現中文檔名的檔案無法下載, Google 了一下, 發現是 PHP 對於中文檔名支援問題, 試了 iconv 與 mb_convert_encoding 兩種函式將 big5 轉成 utf-8 來處理都不得法, 上傳 OK 但下載 NG (因為 PHP 的 basename 不支援中文) :

$file_name=$_FILES[$uploader]["name"];
$new_name=$path.mb_convert_encoding($file_name,"big5","utf-8");

我參考葉建榮的 "PHP 與 MySQL 網站規劃管理應用" 這本書的作法, 乾脆限制只能上傳英數檔名之檔案, 然後用檔案說明欄描述之. 當然這樣做有些不完美, 或許可以參考下面這個網站的建議處理中文檔名上傳問題 :

# php 中文檔名上傳問題

他的做法是上傳到伺服器後改以英數字檔名儲存 (例如日期時間+tmp_name), 但是把中文檔名記錄在資料庫裡, 檔案列表顯示的是資料庫所記錄的原始中文檔名, 但下載時卻連結到相對應的英數字檔名. 這個做法不錯, 但是必須於資料表中增加一個欄位來記錄真實檔名, 以便與原始中文檔名對應.


沒有留言 :