liuhairui 2 settimane fa
parent
commit
fbddf51266

+ 7 - 15
application/admin/view/index/login.html

@@ -7,7 +7,7 @@
             width: 100%;
             height: 100vh;
             background-image: url("http://xh-erp.7in6.com/img/bg1.jpg");
-            background-size: cover;
+            background-size: cover; /* 等比例放大或缩小背景图以完全覆盖容器 */
             background-repeat: no-repeat;
             display: flex;
             flex-direction: column;
@@ -15,7 +15,7 @@
             align-items: center;
             margin: 0;
             position: relative;
-            /*background-position: center;*/
+            background-position: center;
             filter: brightness(1.2); /* 调整亮度 */
         }
 
@@ -26,28 +26,23 @@
         .login-wrapper {
             display: flex;
             flex-direction: column;
-            align-items: flex-end;
-            flex: 1 10 1;
+            align-items: center;
             width: 100%;
             padding: 20px;
             box-sizing: border-box;
-            margin-right: 20%;margin-top: 10%;
             position: relative;
         }
 
-
         .login-screen {
             width: 100%;
-            max-width: 475px;
-            /* display: flex; */
+            max-width: 600px;
+            display: flex;
             flex-direction: column;
-            align-items: end;
+            align-items: center;
             border-radius: 3px;
             box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
-            padding: 15px;
+            padding: 20px;
             box-sizing: border-box;
-
-            background-color: rgba(115, 162, 229, 0.1);
         }
 
         .profile-img-card {
@@ -106,7 +101,6 @@
         .head .head-title {
             font-size: 24px;
             margin: 0;
-            margin-top: 30px;
             text-align: center;
             color: whitesmoke;
             font-family: 'Songti', '宋体', serif;
@@ -193,8 +187,6 @@
     <p class="head-title">浙江新华数码印务有限公司</p>
 </div>
 <div class="login-wrapper">
-    <div class="tmbg">
-    </div>
     <div class="login-screen">
         <p class="head-title-logo">生产经营驾驶舱系统</p>
         <div>

+ 346 - 142
application/index/controller/Index.php

@@ -11,7 +11,7 @@ use think\Session;
 
 /**
  * 手机端:外发明细(purchase_order_detail)验证码 / 账号密码登录 + 列表
- * 手机验证码:customer 登记手机号为普通用户(按 customer 公司名筛明细、可填金额/交期);否则 admin.mobile 命中为管理员(看全部、仅查看);账号密码为 admin 登录(看全部、仅查看)
+ * 普通用户:customer_user(手机号验证码 或 账号密码);管理员:admin 表账号密码(看全部、仅查看)
  */
 class Index extends Frontend
 {
@@ -259,7 +259,109 @@ class Index extends Frontend
     }
 
     /**
-     * 登录手机号对应的外协单位名称:优先 customer 表公司名/名称;否则取 purchase_order_detail 该手机最新一条
+     * customer_user 是否允许登录(status:1 / 正常)
+     */
+    protected function mprocCustomerUserActive(array $row): bool
+    {
+        $st = $row['status'] ?? '';
+        return $st === 1 || $st === '1';
+    }
+
+    /**
+     * @return array<string, mixed>|null
+     */
+    protected function mprocFindCustomerUserByMobile(string $phone): ?array
+    {
+        $phone = trim($phone);
+        if ($phone === '' || !preg_match('/^1\d{10}$/', $phone)) {
+            return null;
+        }
+        try {
+            $row = Db::table('customer_user')->where('mobile', $phone)->order('id', 'asc')->find();
+        } catch (\Throwable $e) {
+            return null;
+        }
+        if (!is_array($row) || $row === [] || !$this->mprocCustomerUserActive($row)) {
+            return null;
+        }
+
+        return $row;
+    }
+
+    /**
+     * @return array<string, mixed>|null
+     */
+    protected function mprocFindCustomerUserByUsername(string $username): ?array
+    {
+        $username = trim($username);
+        if ($username === '') {
+            return null;
+        }
+        try {
+            $row = Db::table('customer_user')->where('username', $username)->order('id', 'asc')->find();
+        } catch (\Throwable $e) {
+            return null;
+        }
+        if (!is_array($row) || $row === [] || !$this->mprocCustomerUserActive($row)) {
+            return null;
+        }
+
+        return $row;
+    }
+
+    /**
+     * customer_user 密码校验(兼容 md5(md5+salt)、md5(md5) 无 salt)
+     */
+    protected function mprocVerifyCustomerUserPassword(array $row, string $password): bool
+    {
+        $stored = (string)($row['password'] ?? '');
+        if ($stored === '' || $password === '') {
+            return false;
+        }
+        if (preg_match('/^\$2[ayb]\$/', $stored)) {
+            return password_verify($password, $stored);
+        }
+        $salt = (string)($row['salt'] ?? '');
+        $hash = md5(md5($password) . $salt);
+
+        return hash_equals($stored, $hash) || ($salt === '' && hash_equals($stored, md5(md5($password))));
+    }
+
+    /**
+     * 生成 customer_user 登录密码密文(与校验规则一致,保留原 salt)
+     */
+    protected function mprocHashCustomerUserPassword(string $password, string $existingSalt = ''): string
+    {
+        return md5(md5($password) . $existingSalt);
+    }
+
+    /**
+     * 写入手机端登录态并返回跳转 URL
+     *
+     * @param array<string, mixed> $userData
+     */
+    protected function mprocFinishLogin(array $userData): void
+    {
+        $old = Session::get('mproc_token');
+        if ($old) {
+            Cache::rm('mproc_u_' . preg_replace('/[^a-f0-9]/i', '', (string)$old));
+        }
+        $token = bin2hex(random_bytes(16));
+        $userData['login_time'] = time();
+        Cache::set('mproc_u_' . $token, $userData, $this->mprocTtlSeconds + 86400);
+        Session::set('mproc_token', $token);
+        Cookie::set('mproc_token', $token, $this->mprocTtlSeconds);
+
+        $postR = $this->mprocSanitizeRedirectUrl($this->request->post('redirect', ''));
+        $sessR = $this->mprocSanitizeRedirectUrl((string)Session::get('mproc_intended_url', ''));
+        Session::delete('mproc_intended_url');
+        $raw = $postR !== '' ? $postR : $sessR;
+        $jump = $this->mprocBuildAfterLoginIndexUrl($raw);
+        $this->success('登录成功', $jump);
+    }
+
+    /**
+     * 登录手机号对应的外协单位名称:优先 customer_user;再 customer 表;否则 purchase_order_detail
      */
     protected function mprocResolveCompanyForLoginPhone(string $phone): string
     {
@@ -267,6 +369,13 @@ class Index extends Frontend
         if ($phone === '' || !preg_match('/^1\d{10}$/', $phone)) {
             return '';
         }
+        $cu = $this->mprocFindCustomerUserByMobile($phone);
+        if (is_array($cu) && $cu !== []) {
+            $co = trim((string)($cu['company_name'] ?? ''));
+            if ($co !== '') {
+                return $co;
+            }
+        }
         $cust = $this->mprocFindCustomerRowByPhone($phone);
         if (is_array($cust) && $cust !== []) {
             $co = $this->mprocCustomerPickField($cust, ['company_name', 'name']);
@@ -511,84 +620,152 @@ class Index extends Frontend
     }
 
     /**
-     * 「我的」:公司名称、姓名、手机、邮箱以 customer 表为准;无匹配时回退 purchase_order_detail
+     * 按登录态解析 customer_user 行(id / 用户名 / 手机号)
+     *
+     * @return array<string, mixed>|null
      */
-    protected function mprocProfileForUser(array $user)
+    protected function mprocResolveCustomerUserForSession(array $user): ?array
     {
-        $phone = trim((string)($user['phone'] ?? ''));
-        $out = [
-            'company_name' => trim((string)($user['company_name'] ?? '')),
-            'contact_name' => '',
-            'phone'        => $phone,
-            'email'        => '',
-        ];
-        if ($phone === '' && !empty($user['username'])) {
-            if ($out['company_name'] === '') {
-                $out['company_name'] = '账号:' . (string)$user['username'];
-            }
-            return $out;
+        if (!empty($user['is_admin'])) {
+            return null;
         }
-
-        $cust = $this->mprocFindCustomerRowForUser($user);
-        if (is_array($cust) && $cust !== []) {
-            $co = $this->mprocCustomerPickField($cust, ['company_name', 'name']);
-            if ($co !== '') {
-                $out['company_name'] = $co;
+        $cuId = (int)($user['customer_user_id'] ?? 0);
+        if ($cuId > 0) {
+            try {
+                $row = Db::table('customer_user')->where('id', $cuId)->find();
+            } catch (\Throwable $e) {
+                $row = null;
             }
-            $out['contact_name'] = $this->mprocCustomerPickField($cust, ['username', 'contact', 'linkman', 'contacts']);
-            $em = $this->mprocCustomerPickField($cust, ['email']);
-            if ($em !== '') {
-                $out['email'] = $em;
+            if (is_array($row) && $row !== [] && $this->mprocCustomerUserActive($row)) {
+                return $row;
             }
-            $rawP = $this->mprocCustomerPickField($cust, ['phone']);
-            if ($rawP !== '') {
-                $norm = str_replace(["\r", "\n", "\t", ' ', ' ', ','], ['', '', '', '', '', ','], $rawP);
-                $segs = [];
-                foreach (explode(',', $norm) as $seg) {
-                    $seg = trim($seg);
-                    if ($seg !== '') {
-                        $segs[] = $seg;
-                    }
-                }
-                if ($phone !== '' && $segs !== [] && in_array($phone, $segs, true)) {
-                    $out['phone'] = $phone;
-                } elseif ($segs !== []) {
-                    $out['phone'] = implode('、', $segs);
-                } else {
-                    $out['phone'] = $rawP;
-                }
+        }
+        $uname = trim((string)($user['username'] ?? ''));
+        if ($uname !== '') {
+            $byName = $this->mprocFindCustomerUserByUsername($uname);
+            if ($byName !== null) {
+                return $byName;
             }
-            return $out;
+        }
+        $phone = trim((string)($user['phone'] ?? ''));
+        if ($phone !== '' && preg_match('/^1\d{10}$/', $phone)) {
+            return $this->mprocFindCustomerUserByMobile($phone);
+        }
+
+        return null;
+    }
+
+    /**
+     * customer_user 表字段 →「我的」展示结构
+     *
+     * @param array<string, mixed> $cu
+     * @return array{company_name:string,contact_name:string,phone:string,email:string}
+     */
+    protected function mprocProfileFromCustomerUserRow(array $cu): array
+    {
+        $nm = trim((string)($cu['nickname'] ?? ''));
+        if ($nm === '') {
+            $nm = trim((string)($cu['username'] ?? ''));
         }
 
+        return [
+            'company_name' => trim((string)($cu['company_name'] ?? '')),
+            'contact_name' => $nm,
+            'phone'        => trim((string)($cu['mobile'] ?? '')),
+            'email'        => trim((string)($cu['email'] ?? '')),
+        ];
+    }
+
+    /**
+     * 管理员「我的」:admin 表
+     *
+     * @return array{company_name:string,contact_name:string,phone:string,email:string}
+     */
+    protected function mprocProfileForAdmin(array $user): array
+    {
+        $uname = trim((string)($user['username'] ?? ''));
+        $out = [
+            'company_name' => '管理员',
+            'contact_name' => $uname !== '' ? $uname : '管理员',
+            'phone'        => trim((string)($user['phone'] ?? '')),
+            'email'        => '',
+        ];
+        if ($uname === '') {
+            return $out;
+        }
         try {
-            $one = null;
-            $cCol = $this->mprocResolveProcuremenColumn(['company_name']);
-            $co = $out['company_name'];
-            if ($cCol && $co !== '') {
-                $one = Db::table('purchase_order_detail')->where($cCol, $co)->order('id', 'desc')->find();
-            }
-            if (!is_array($one) && $phone !== '') {
-                $one = Db::table('purchase_order_detail')
-                    ->where('phone', $phone)
-                    ->order('id', 'desc')
-                    ->find();
-            }
-            if (is_array($one)) {
-                if ($out['company_name'] === '') {
-                    $out['company_name'] = trim((string)($one['company_name'] ?? ''));
-                }
-                $out['email'] = trim((string)($one['email'] ?? ''));
-                $rp = trim((string)($one['phone'] ?? ''));
-                if ($rp !== '') {
-                    $out['phone'] = $rp;
-                }
-            }
+            $row = Db::name('admin')->where('username', $uname)->find();
         } catch (\Throwable $e) {
+            $row = null;
+        }
+        if (!is_array($row) || $row === []) {
+            return $out;
+        }
+        $nick = trim((string)($row['nickname'] ?? ''));
+        if ($nick !== '') {
+            $out['contact_name'] = $nick;
+        }
+        $mob = trim((string)($row['mobile'] ?? ''));
+        if ($mob !== '') {
+            $out['phone'] = $mob;
+        }
+        $em = trim((string)($row['email'] ?? ''));
+        if ($em !== '') {
+            $out['email'] = $em;
         }
+
         return $out;
     }
 
+    /**
+     * 旧会话补全 customer_user_id 等字段(登录后改表结构时无需重新登录)
+     */
+    protected function mprocSyncSessionCustomerUser(array $user): array
+    {
+        if (!empty($user['is_admin'])) {
+            return $user;
+        }
+        $cu = $this->mprocResolveCustomerUserForSession($user);
+        if (!$cu) {
+            return $user;
+        }
+        $user['customer_user_id'] = (int)($cu['id'] ?? 0);
+        $user['username'] = trim((string)($cu['username'] ?? $user['username'] ?? ''));
+        $user['company_name'] = trim((string)($cu['company_name'] ?? ''));
+        $mob = trim((string)($cu['mobile'] ?? ''));
+        if ($mob !== '') {
+            $user['phone'] = $mob;
+        }
+        $token = Session::get('mproc_token');
+        if ($token) {
+            $user['login_time'] = (int)($user['login_time'] ?? time());
+            Cache::set('mproc_u_' . preg_replace('/[^a-f0-9]/i', '', (string)$token), $user, $this->mprocTtlSeconds + 86400);
+        }
+
+        return $user;
+    }
+
+    /**
+     * 「我的」:普通用户仅 customer_user;管理员仅 admin
+     */
+    protected function mprocProfileForUser(array $user)
+    {
+        if (!empty($user['is_admin'])) {
+            return $this->mprocProfileForAdmin($user);
+        }
+        $cu = $this->mprocResolveCustomerUserForSession($user);
+        if (is_array($cu) && $cu !== []) {
+            return $this->mprocProfileFromCustomerUserRow($cu);
+        }
+
+        return [
+            'company_name' => trim((string)($user['company_name'] ?? '')),
+            'contact_name' => trim((string)($user['username'] ?? '')),
+            'phone'        => trim((string)($user['phone'] ?? '')),
+            'email'        => '',
+        ];
+    }
+
     /**
      * 将 purchase_order(工序行主表)快照合并进 purchase_order_detail 行:订单级信息以主表为准;
      * 金额、交期、外厂 company、明细 status 等仍保留明细表。
@@ -749,6 +926,7 @@ class Index extends Frontend
 
             return;
         }
+        $user = $this->mprocSyncSessionCustomerUser($user);
 
         $tabParam = trim((string)$this->request->get('tab', 'draft'));
         $mainTab = trim((string)$this->request->get('main_tab', 'orders'));
@@ -802,6 +980,7 @@ class Index extends Frontend
         $this->view->assign('mprocSearchQ', $q);
         $this->view->assign('mprocProfile', $profile);
         $this->view->assign('mprocIsAdmin', !empty($user['is_admin']) ? 1 : 0);
+        $this->view->assign('mprocCanChangePwd', empty($user['is_admin']) && (int)($user['customer_user_id'] ?? 0) > 0 ? 1 : 0);
         $this->view->assign('mprocFocusEid', $mprocFocusEid);
 
         if ($mainTab === 'me') {
@@ -826,6 +1005,7 @@ class Index extends Frontend
         if (!$user) {
             $this->error('请先登录', url('index/index/login'));
         }
+        $user = $this->mprocSyncSessionCustomerUser($user);
         $tabParam = trim((string)$this->request->request('tab', 'draft'));
         $mainTab = trim((string)$this->request->request('main_tab', 'orders'));
         if ($tabParam === 'me') {
@@ -889,8 +1069,8 @@ class Index extends Frontend
         if (!preg_match('/^1\d{10}$/', $phone)) {
             $this->error('请输入正确的11位手机号');
         }
-        if (!$this->mprocFindCustomerRowByPhone($phone) && !$this->mprocAdminRowByMobile($phone)) {
-            $this->error('账号未开通权限,请联系管理员开通');
+        if (!$this->mprocFindCustomerUserByMobile($phone)) {
+            $this->error('该手机号未开通或已禁用,请联系管理员');
         }
         $cd = (int)(Config::get('mproc.sms_resend_cd') ?: 55);
         if (Cache::get('mproc_sms_wait_' . $phone)) {
@@ -940,57 +1120,28 @@ class Index extends Frontend
             Cache::rm('mproc_code_' . $phone);
         }
 
-        $cust = $this->mprocFindCustomerRowByPhone($phone);
-        if (is_array($cust) && $cust !== []) {
-            $isAdmin = 0;
-            $companyName = $this->mprocCustomerPickField($cust, ['company_name', 'name']);
-            if ($companyName === '') {
-                try {
-                    $one = Db::table('purchase_order_detail')->where('phone', $phone)->order('id', 'desc')->find();
-                    if (is_array($one)) {
-                        $companyName = trim((string)($one['company_name'] ?? ''));
-                    }
-                } catch (\Throwable $e) {
-                }
-            }
-        } elseif ($this->mprocAdminRowByMobile($phone)) {
-            $isAdmin = 1;
-            $companyName = '';
-        } else {
-            $this->error('账号未开通权限,请联系管理员开通');
+        $cu = $this->mprocFindCustomerUserByMobile($phone);
+        if (!$cu) {
+            $this->error('该手机号未开通或已禁用,请联系管理员');
         }
-
-        $old = Session::get('mproc_token');
-        if ($old) {
-            Cache::rm('mproc_u_' . preg_replace('/[^a-f0-9]/i', '', (string)$old));
+        $companyName = trim((string)($cu['company_name'] ?? ''));
+        if ($companyName === '') {
+            $companyName = $this->mprocResolveCompanyForLoginPhone($phone);
         }
 
-        $token = bin2hex(random_bytes(16));
-        $userData = [
-            'phone'         => $phone,
-            'company_name'  => $companyName,
-            'username'      => '',
-            'login_type'    => 'sms',
-            'is_admin'      => $isAdmin ? 1 : 0,
-            'login_time'    => time(),
-        ];
-        // 缓存略长于逻辑有效期,过期以 login_time 为准
-        Cache::set('mproc_u_' . $token, $userData, $this->mprocTtlSeconds + 86400);
-        Session::set('mproc_token', $token);
-        Cookie::set('mproc_token', $token, $this->mprocTtlSeconds);
-
-        $postR = $this->mprocSanitizeRedirectUrl($this->request->post('redirect', ''));
-        $sessR = $this->mprocSanitizeRedirectUrl((string)Session::get('mproc_intended_url', ''));
-        Session::delete('mproc_intended_url');
-        $raw = $postR !== '' ? $postR : $sessR;
-        $jump = $this->mprocBuildAfterLoginIndexUrl($raw);
-        $this->success('登录成功', $jump);
+        $this->mprocFinishLogin([
+            'phone'            => $phone,
+            'company_name'     => $companyName,
+            'username'         => trim((string)($cu['username'] ?? '')),
+            'customer_user_id' => (int)($cu['id'] ?? 0),
+            'login_type'       => 'sms',
+            'is_admin'         => 0,
+        ]);
     }
 
     /**
      * 账号密码登录(POST:username、password)
-     * 与后台 FastAdmin 一致:表 admin、密码 md5(md5(明文)+salt)、状态禁用与失败锁定规则同 admin/library/Auth::login
-     * 成功后仅建立手机端外发明细会话(不写后台 Session,避免与 PC 后台互踢登录态)
+     * 先 customer_user(普通用户),未命中再 admin(管理员);admin 密码规则同 FastAdmin Auth::login
      */
     public function doLoginPwd()
     {
@@ -1003,7 +1154,27 @@ class Index extends Frontend
             $this->error('请输入账号和密码');
         }
 
-        // 直接用 Db 查 admin,避免加载 Admin 模型与 AdminAuth(连带 fast\Auth),缩短首包时间
+        $cu = $this->mprocFindCustomerUserByUsername($username);
+        if ($cu) {
+            if (!$this->mprocVerifyCustomerUserPassword($cu, $password)) {
+                $this->error('账号或密码错误');
+            }
+            $phone = trim((string)($cu['mobile'] ?? ''));
+            $companyName = trim((string)($cu['company_name'] ?? ''));
+            if ($companyName === '' && $phone !== '' && preg_match('/^1\d{10}$/', $phone)) {
+                $companyName = $this->mprocResolveCompanyForLoginPhone($phone);
+            }
+            $this->mprocFinishLogin([
+                'phone'            => $phone,
+                'company_name'     => $companyName,
+                'username'         => trim((string)($cu['username'] ?? '')),
+                'customer_user_id' => (int)($cu['id'] ?? 0),
+                'login_type'       => 'pwd',
+                'is_admin'         => 0,
+            ]);
+        }
+
+        // 管理员:表 admin
         $row = null;
         try {
             $row = Db::name('admin')
@@ -1050,44 +1221,25 @@ class Index extends Frontend
             }
         }
 
-        $old = Session::get('mproc_token');
-        if ($old) {
-            Cache::rm('mproc_u_' . preg_replace('/[^a-f0-9]/i', '', (string)$old));
-        }
-
-        $token = bin2hex(random_bytes(16));
-        $userData = [
-            'phone'         => '',
-            'company_name'  => '',
-            'username'      => $username,
-            'login_type'    => 'pwd',
-            'is_admin'      => 1,
-            'login_time'    => time(),
-        ];
-        Cache::set('mproc_u_' . $token, $userData, $this->mprocTtlSeconds + 86400);
-        Session::set('mproc_token', $token);
-        Cookie::set('mproc_token', $token, $this->mprocTtlSeconds);
-
-        $postR = $this->mprocSanitizeRedirectUrl($this->request->post('redirect', ''));
-        $sessR = $this->mprocSanitizeRedirectUrl((string)Session::get('mproc_intended_url', ''));
-        Session::delete('mproc_intended_url');
-        $raw = $postR !== '' ? $postR : $sessR;
-        $jump = $this->mprocBuildAfterLoginIndexUrl($raw);
-        $this->success('登录成功', $jump);
+        $this->mprocFinishLogin([
+            'phone'            => trim((string)($row['mobile'] ?? '')),
+            'company_name'     => '',
+            'username'         => $username,
+            'customer_user_id' => 0,
+            'login_type'       => 'pwd',
+            'is_admin'         => 1,
+        ]);
     }
 
     /**
      * 是否允许当前登录用户修改该条 purchase_order_detail 的金额、交期
-     * 仅普通外协用户(短信登录且非管理员)可改;管理员手机号、账号密码登录仅可查看
+     * 仅普通用户(customer_user)可改;管理员(admin 账号密码)仅可查看
      */
     protected function mprocCanEditRow(array $user, array $row)
     {
         if (!empty($user['is_admin'])) {
             return false;
         }
-        if (($user['login_type'] ?? '') === 'pwd') {
-            return false;
-        }
         $uCo = trim((string)($user['company_name'] ?? ''));
         if ($uCo === '') {
             $uPhone = trim((string)($user['phone'] ?? ''));
@@ -1133,7 +1285,7 @@ class Index extends Frontend
             $this->error('记录不存在');
         }
         if (!$this->mprocCanEditRow($user, $row)) {
-            if (!empty($user['is_admin']) || (($user['login_type'] ?? '') === 'pwd')) {
+            if (!empty($user['is_admin'])) {
                 $this->error('当前账号仅可查看,不能修改金额与交货日期');
             }
             $this->error('无权修改该记录');
@@ -1216,6 +1368,58 @@ class Index extends Frontend
         $this->success('已保存');
     }
 
+    /**
+     * 普通用户修改密码(POST:old_password、new_password、renew_password)
+     */
+    public function mprocChangePwd()
+    {
+        if (!$this->request->isPost()) {
+            $this->error('请使用 POST');
+        }
+        $user = $this->mprocGetUser();
+        if (!$user) {
+            $this->error('请先登录', url('index/index/login'));
+        }
+        $user = $this->mprocSyncSessionCustomerUser($user);
+        if (!empty($user['is_admin'])) {
+            $this->error('当前账号不支持修改密码');
+        }
+        $cu = $this->mprocResolveCustomerUserForSession($user);
+        if (!$cu) {
+            $this->error('账号不存在或已禁用');
+        }
+        $oldPwd = (string)$this->request->post('old_password', '');
+        $newPwd = (string)$this->request->post('new_password', '');
+        $renewPwd = (string)$this->request->post('renew_password', '');
+        if ($oldPwd === '' || $newPwd === '' || $renewPwd === '') {
+            $this->error('请填写完整');
+        }
+        if (strlen($newPwd) < 6) {
+            $this->error('新密码至少6位');
+        }
+        if ($newPwd !== $renewPwd) {
+            $this->error('两次输入的新密码不一致');
+        }
+        if ($oldPwd === $newPwd) {
+            $this->error('新密码不能与旧密码相同');
+        }
+        $cuId = (int)($cu['id'] ?? 0);
+        if (!$this->mprocVerifyCustomerUserPassword($cu, $oldPwd)) {
+            $this->error('原密码不正确');
+        }
+        $salt = (string)($cu['salt'] ?? '');
+        $data = [
+            'password'   => $this->mprocHashCustomerUserPassword($newPwd, $salt),
+            'updatetime' => date('Y-m-d H:i:s'),
+        ];
+        try {
+            Db::table('customer_user')->where('id', $cuId)->update($data);
+        } catch (\Throwable $e) {
+            $this->error('修改失败:' . $e->getMessage());
+        }
+        $this->success('密码已修改');
+    }
+
     /**
      * 退出登录
      */

+ 121 - 1
application/index/view/index/index.html

@@ -117,6 +117,20 @@
         .me-row:last-child { border-bottom: none; }
         .me-row span { display: inline-block; min-width: 4.5em; color: #888; }
         .me-hint { font-size: 12px; color: #999; margin-top: 14px; line-height: 1.5; }
+        .btn-me-pwd {
+            display: block;
+            width: 100%;
+            margin-top: 16px;
+            padding: 12px;
+            border: 1px solid #3c8dbc;
+            border-radius: 8px;
+            background: #fff;
+            color: #3c8dbc;
+            font-size: 15px;
+            font-weight: 600;
+            cursor: pointer;
+        }
+        .btn-me-pwd:active { opacity: .85; }
         .tabbar {
             position: fixed;
             left: 0;
@@ -265,6 +279,9 @@
         <div class="me-row"><span>姓名</span>{$mprocProfile.contact_name|default=''|htmlentities}</div>
         <div class="me-row"><span>手机号</span>{$mprocProfile.phone|default=''|htmlentities}</div>
         <div class="me-row"><span>邮箱</span>{$mprocProfile.email|default=''|htmlentities}</div>
+        {if $mprocCanChangePwd}
+        <button type="button" class="btn-me-pwd" id="btn-me-change-pwd">修改密码</button>
+        {/if}
     </div>
 </div>
 
@@ -273,6 +290,28 @@
     <button type="button" class="tabbar-btn {eq name='mprocMainTab' value='me'}active{/eq}" data-main-tab="me">我的</button>
 </nav>
 
+<div class="modal-mask" id="pwd-mask" aria-hidden="true">
+    <div class="modal-sheet" id="pwd-sheet">
+        <p class="modal-head">修改密码</p>
+        <div class="modal-field">
+            <label for="inp-old-pwd">原密码</label>
+            <input type="password" id="inp-old-pwd" autocomplete="current-password" maxlength="64">
+        </div>
+        <div class="modal-field">
+            <label for="inp-new-pwd">新密码</label>
+            <input type="password" id="inp-new-pwd" autocomplete="new-password" maxlength="64">
+        </div>
+        <div class="modal-field">
+            <label for="inp-renew-pwd">确认新密码</label>
+            <input type="password" id="inp-renew-pwd" autocomplete="new-password" maxlength="64">
+        </div>
+        <div class="modal-actions">
+            <button type="button" class="btn-cancel" id="pwd-cancel">取消</button>
+            <button type="button" class="btn-ok" id="pwd-save">确定</button>
+        </div>
+    </div>
+</div>
+
 <div class="modal-mask" id="edit-mask" aria-hidden="true">
     <div class="modal-sheet" id="edit-sheet">
         <p class="modal-head" id="edit-sheet-title">编辑</p>
@@ -310,7 +349,8 @@
     }
     var listUrl = mprocEndpointUrl('mprocList.html');
     var saveUrl = mprocEndpointUrl('mprocSave.html');
-    var boot = {:json_encode(['main_tab' => $mprocMainTab, 'tab' => $mprocTab, 'q' => $mprocSearchQ, 'is_admin' => $mprocIsAdmin, 'focus_eid' => isset($mprocFocusEid) ? (int)$mprocFocusEid : 0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)};
+    var boot = {:json_encode(['main_tab' => $mprocMainTab, 'tab' => $mprocTab, 'q' => $mprocSearchQ, 'is_admin' => $mprocIsAdmin, 'can_change_pwd' => isset($mprocCanChangePwd) ? (int)$mprocCanChangePwd : 0, 'focus_eid' => isset($mprocFocusEid) ? (int)$mprocFocusEid : 0], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)};
+    var changePwdUrl = mprocEndpointUrl('mprocChangePwd.html');
     var currentMain = boot.main_tab === 'me' ? 'me' : 'orders';
     var currentListTab = boot.tab && ['draft', 'submitted', 'done'].indexOf(boot.tab) !== -1 ? boot.tab : 'draft';
 
@@ -839,6 +879,86 @@
     }
     setTimeout(mprocTryScrollToFocusCard, 200);
     setTimeout(mprocTryScrollToFocusCard, 650);
+
+    if (boot.can_change_pwd) {
+        var pwdMask = document.getElementById('pwd-mask');
+        var pwdSheet = document.getElementById('pwd-sheet');
+        var btnMePwd = document.getElementById('btn-me-change-pwd');
+        var inpOldPwd = document.getElementById('inp-old-pwd');
+        var inpNewPwd = document.getElementById('inp-new-pwd');
+        var inpRenewPwd = document.getElementById('inp-renew-pwd');
+        var btnPwdSave = document.getElementById('pwd-save');
+        var btnPwdCancel = document.getElementById('pwd-cancel');
+
+        function openPwdModal() {
+            if (inpOldPwd) inpOldPwd.value = '';
+            if (inpNewPwd) inpNewPwd.value = '';
+            if (inpRenewPwd) inpRenewPwd.value = '';
+            pwdMask.classList.add('show');
+            pwdMask.setAttribute('aria-hidden', 'false');
+        }
+        function closePwdModal() {
+            pwdMask.classList.remove('show');
+            pwdMask.setAttribute('aria-hidden', 'true');
+        }
+        if (btnMePwd) {
+            btnMePwd.addEventListener('click', openPwdModal);
+        }
+        if (btnPwdCancel) {
+            btnPwdCancel.addEventListener('click', closePwdModal);
+        }
+        if (pwdMask) {
+            pwdMask.addEventListener('click', function (e) {
+                if (e.target === pwdMask) closePwdModal();
+            });
+        }
+        if (pwdSheet) {
+            pwdSheet.addEventListener('click', function (e) { e.stopPropagation(); });
+        }
+        if (btnPwdSave) {
+            btnPwdSave.addEventListener('click', function () {
+                var oldP = inpOldPwd ? inpOldPwd.value : '';
+                var newP = inpNewPwd ? inpNewPwd.value : '';
+                var renP = inpRenewPwd ? inpRenewPwd.value : '';
+                if (!oldP || !newP || !renP) {
+                    alert('请填写完整');
+                    return;
+                }
+                if (newP.length < 6) {
+                    alert('新密码至少6位');
+                    return;
+                }
+                if (newP !== renP) {
+                    alert('两次输入的新密码不一致');
+                    return;
+                }
+                btnPwdSave.disabled = true;
+                var body = 'old_password=' + encodeURIComponent(oldP)
+                    + '&new_password=' + encodeURIComponent(newP)
+                    + '&renew_password=' + encodeURIComponent(renP);
+                fetch(changePwdUrl, {
+                    method: 'POST',
+                    headers: {
+                        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+                        'X-Requested-With': 'XMLHttpRequest'
+                    },
+                    body: body,
+                    credentials: 'same-origin'
+                }).then(function (r) { return r.json(); }).then(function (ret) {
+                    btnPwdSave.disabled = false;
+                    if (ret && (ret.code === 1 || ret.code === '1')) {
+                        closePwdModal();
+                        alert(ret.msg || '密码已修改');
+                    } else {
+                        alert(ret && ret.msg ? ret.msg : '修改失败');
+                    }
+                }).catch(function () {
+                    btnPwdSave.disabled = false;
+                    alert('网络错误');
+                });
+            });
+        }
+    }
 })();
 </script>
 </body>

+ 14 - 14
application/index/view/index/login.html

@@ -102,11 +102,22 @@
         <h1>登录</h1>
     </div>
     <div class="login-tabs" role="tablist">
-        <button type="button" class="active" data-tab="phone">手机号登录</button>
-        <button type="button" data-tab="account">账号密码</button>
+        <button type="button" class="active" data-tab="account">账号密码</button>
+        <button type="button" data-tab="phone">手机号登录</button>
     </div>
     <div class="card">
-        <div id="panel-phone" class="panel active">
+        <div id="panel-account" class="panel active">
+            <div class="field">
+                <label for="username">账号</label>
+                <input type="text" id="username" name="username" maxlength="64" placeholder="用户名" autocomplete="username">
+            </div>
+            <div class="field">
+                <label for="password">密码</label>
+                <input type="password" id="password" name="password" placeholder="密码" autocomplete="current-password">
+            </div>
+            <button type="button" class="btn-submit" id="btn-login-pwd">登 录</button>
+        </div>
+        <div id="panel-phone" class="panel">
             <div class="field">
                 <label for="phone">手机号</label>
                 <input type="tel" id="phone" name="phone" maxlength="11" placeholder="请输入11位手机号" autocomplete="tel">
@@ -120,17 +131,6 @@
             </div>
             <button type="button" class="btn-submit" id="btn-login">登 录</button>
         </div>
-        <div id="panel-account" class="panel">
-            <div class="field">
-                <label for="username">账号</label>
-                <input type="text" id="username" name="username" maxlength="64" placeholder="用户名" autocomplete="username">
-            </div>
-            <div class="field">
-                <label for="password">密码</label>
-                <input type="password" id="password" name="password" placeholder="密码" autocomplete="current-password">
-            </div>
-            <button type="button" class="btn-submit" id="btn-login-pwd">登 录</button>
-        </div>
     </div>
 </div>
 </div>