refactor: 重构后端目录结构和命名规范

- 将后端目录名从大写改为小写 (Controller->controller 等)
- 将命名空间从 App\ 改为 app\ (符合 webman 官方规范)
- 移除不必要的 bootstrap 目录
- 完善出差审批业务功能:
  - 添加删除草稿功能
  - 添加审批操作功能(同意/拒绝)
  - 完善审批详情页,显示审批人和审批记录
- 前端优化:
  - 调整列表页行高
  - 隐藏底部导航栏
  - 仪表盘只显示3个快捷入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-25 18:09:04 +08:00
parent 09d4cb8054
commit 582f454a5a
47 changed files with 1297 additions and 360 deletions
+27
View File
@@ -251,6 +251,33 @@ const handleSubmit = async () => {
- `frontend/src/pages/business-trip/select-cc.tsx`
- `frontend/src/pages/business-trip/apply.tsx`
#### 问题3:导航时草稿保存不及时
**现象:** 用户输入内容后快速点击选择人员,返回时输入的内容丢失
**原因:** React 的 useEffect 是异步执行的,用户快速导航时,useEffect 可能还没来得及保存到 sessionStorage
**解决方案:** 在导航处理函数中立即保存草稿
```tsx
const handleSelectApprovers = () => {
// 导航前立即保存草稿
const draftData = { reason, itineraries }
sessionStorage.setItem('businessTripDraft', JSON.stringify(draftData))
navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } })
}
const handleSelectCc = () => {
// 导航前立即保存草稿
const draftData = { reason, itineraries }
sessionStorage.setItem('businessTripDraft', JSON.stringify(draftData))
navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } })
}
```
**关键点:**
- ✅ 导航前同步保存草稿数据,不依赖 useEffect
- ✅ 保留 useEffect 自动保存作为备份机制(防止用户不导航就离开页面)
### Ant Design Mobile DatePicker 点击无反应
**问题:** DatePicker 组件使用 children render prop 方式时,点击无法弹出选择框
-15
View File
@@ -1,15 +0,0 @@
<?php
namespace App\Bootstrap;
use App\Util\Db;
use Webman\Bootstrap;
class Database implements Bootstrap
{
public static function start(?\Workerman\Worker $worker): void
{
// 初始化数据库连接
Db::init();
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
namespace App\Middleware;
use App\Util\JwtUtil;
use Webman\Http\Request;
use Webman\Http\Response;
class AuthMiddleware
{
public function process(Request $request, callable $next): Response
{
// 从请求头获取 token
$token = $request->header('Authorization');
$token = str_replace('Bearer ', '', $token);
// 如果没有 token,返回 401
if (!$token) {
return json([
'code' => 401,
'message' => '未登录',
'data' => null,
]);
}
// 验证 token
$payload = JwtUtil::decode($token);
if (!$payload) {
return json([
'code' => 401,
'message' => '登录已过期',
'data' => null,
]);
}
// 将用户ID注入到请求中
$request->userId = (int) ($payload['user_id'] ?? 0);
$request->companyId = (int) ($payload['company_id'] ?? 0);
return $next($request);
}
}
@@ -1,8 +1,8 @@
<?php
namespace App\Controller;
namespace app\controller;
use App\Service\ApprovalService;
use app\service\ApprovalService;
use Webman\Http\Request;
use Webman\Http\Response;
@@ -1,11 +1,11 @@
<?php
namespace App\Controller;
namespace app\controller;
use App\Service\FeishuService;
use App\Util\JwtUtil;
use App\Model\User;
use App\Model\Company;
use app\service\FeishuService;
use app\util\JwtUtil;
use app\model\User;
use app\model\Company;
use Webman\Http\Request;
use Webman\Http\Response;
@@ -1,6 +1,6 @@
<?php
namespace App\Controller;
namespace app\controller;
use Webman\Http\Request;
use Webman\Http\Response;
@@ -1,8 +1,8 @@
<?php
namespace App\Controller;
namespace app\controller;
use App\Service\BusinessTripService;
use app\service\BusinessTripService;
use Webman\Http\Request;
use Webman\Http\Response;
@@ -144,6 +144,49 @@ class BusinessTripController extends BaseController
}
}
/**
* 删除草稿
*/
public function deleteDraft(Request $request): Response
{
$userId = $this->getUserId($request);
$id = (int) $this->getRouteParam($request, 'id');
if (!$id) {
return $this->error('参数错误');
}
try {
$this->service->deleteDraft($userId, $id);
return $this->success(null, '删除成功');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 审批操作
*/
public function approve(Request $request): Response
{
$userId = $this->getUserId($request);
$id = (int) $this->getRouteParam($request, 'id');
if (!$id) {
return $this->error('参数错误');
}
$data = $request->post();
$data = $data ?? [];
$action = $data['action'] ?? 'approve'; // approve 或 reject
$comment = $data['comment'] ?? '';
try {
$result = $this->service->approve($userId, $id, $action, $comment);
return $this->success($result, $action === 'approve' ? '已同意' : '已拒绝');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 取消出差申请
*/
@@ -1,8 +1,8 @@
<?php
namespace App\Controller;
namespace app\controller;
use App\Model\User;
use app\model\User;
use Webman\Http\Request;
use Webman\Http\Response;
+23 -12
View File
@@ -5,31 +5,42 @@ namespace app\middleware;
use app\util\JwtUtil;
use Webman\Http\Request;
use Webman\Http\Response;
use support\Log;
class AuthMiddleware
{
public function process(Request $request, callable $handler): Response
public function process(Request $request, callable $next): Response
{
// 获取 Token
// 从请求头获取 token
$token = $request->header('Authorization');
$token = str_replace('Bearer ', '', $token);
if (!$token || !JwtUtil::verify($token)) {
// 移除 "Bearer " 前缀(如果存在)
if ($token && str_starts_with($token, 'Bearer ')) {
$token = substr($token, 7);
}
// 如果没有 token,返回 401
if (!$token) {
return json([
'code' => 401,
'message' => 'Unauthorized',
'message' => '未登录',
'data' => null,
], 401);
]);
}
// 解析 Token 并设置用户信息到请求
// 验证 token
$payload = JwtUtil::decode($token);
if ($payload) {
$request->userId = $payload['user_id'] ?? null;
$request->companyId = $payload['company_id'] ?? null;
if (!$payload) {
return json([
'code' => 401,
'message' => '登录已过期',
'data' => null,
]);
}
return $handler($request);
// 将用户ID注入到请求中
$request->userId = (int) ($payload['user_id'] ?? 0);
$request->companyId = (int) ($payload['company_id'] ?? 0);
return $next($request);
}
}
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Model;
namespace app\model;
use Illuminate\Database\Eloquent\Model;
@@ -1,13 +1,13 @@
<?php
namespace App\Service;
namespace app\service;
use App\Model\ApprovalFlow;
use App\Model\ApprovalNode;
use App\Model\ApprovalOrder;
use App\Model\ApprovalRecord;
use App\Model\User;
use App\Util\Db;
use app\model\ApprovalFlow;
use app\model\ApprovalNode;
use app\model\ApprovalOrder;
use app\model\ApprovalRecord;
use app\model\User;
use app\util\Db;
use Illuminate\Database\Connection;
class ApprovalService
@@ -1,14 +1,14 @@
<?php
namespace App\Service;
namespace app\service;
use App\Model\BusinessTrip;
use App\Model\BusinessTripItinerary;
use App\Model\ApprovalOrder;
use App\Model\ApprovalFlow;
use App\Model\ApprovalRecord;
use App\Model\User;
use App\Util\Db;
use app\model\BusinessTrip;
use app\model\BusinessTripItinerary;
use app\model\ApprovalOrder;
use app\model\ApprovalFlow;
use app\model\ApprovalRecord;
use app\model\User;
use app\util\Db;
use Illuminate\Database\Connection;
class BusinessTripService
@@ -165,15 +165,43 @@ class BusinessTripService
switch ($type) {
case 'my':
// 我发起的:当前用户创建的申请
$query->where('user_id', $userId);
break;
case 'pending':
// 获取待审批的申请
// 待我审批:需要查询当前用户是审批人且还未审批的申请
$pendingOrderIds = ApprovalOrder::where('status', ApprovalOrder::STATUS_PENDING)
->where('business_type', 'business_trip')
->pluck('business_id')
->pluck('id')
->toArray();
$query->whereIn('id', $pendingOrderIds);
if (empty($pendingOrderIds)) {
$query->whereRaw('1 = 0'); // 返回空结果
} else {
// 获取这些审批单中当前用户待审批的,排除已审批的
$approvedOrderIds = ApprovalRecord::whereIn('order_id', $pendingOrderIds)
->where('approver_id', $userId)
->whereIn('action', ['approve', 'reject'])
->pluck('order_id')
->toArray();
$pendingOrderIds = array_diff($pendingOrderIds, $approvedOrderIds);
$query->whereIn('approval_order_id', $pendingOrderIds);
}
break;
case 'approved':
// 我已审批:当前用户已经审批过的申请
$approvedOrderIds = ApprovalRecord::where('approver_id', $userId)
->whereIn('action', ['approve', 'reject'])
->pluck('order_id')
->toArray();
if (empty($approvedOrderIds)) {
$query->whereRaw('1 = 0'); // 返回空结果
} else {
$query->whereIn('approval_order_id', $approvedOrderIds);
}
break;
}
@@ -194,10 +222,32 @@ class BusinessTripService
/**
* 获取出差申请详情
*/
public function getDetail(int $id): BusinessTrip
public function getDetail(int $id): array
{
return BusinessTrip::with(['user', 'itineraries'])
$businessTrip = BusinessTrip::with(['user', 'itineraries'])
->findOrFail($id);
$result = $businessTrip->toArray();
// 获取审批单信息
if ($businessTrip->approval_order_id) {
$approvalOrder = ApprovalOrder::with(['records.approver'])
->find($businessTrip->approval_order_id);
if ($approvalOrder) {
$result['approval_order'] = $approvalOrder->toArray();
// 获取审批人和抄送人信息
$formData = $approvalOrder->form_data ?? [];
$result['approvers'] = $formData['approvers'] ?? [];
$result['cc_users'] = $formData['cc_users'] ?? [];
// 获取审批记录
$result['approval_records'] = $approvalOrder->records->toArray();
}
}
return $result;
}
/**
@@ -300,4 +350,113 @@ class BusinessTripService
return $businessTrip->fresh();
}
/**
* 删除草稿
*/
public function deleteDraft(int $userId, int $id): bool
{
$businessTrip = BusinessTrip::findOrFail($id);
if ($businessTrip->user_id !== $userId) {
throw new \Exception('无权操作此申请');
}
if ($businessTrip->status !== BusinessTrip::STATUS_DRAFT) {
throw new \Exception('只能删除草稿');
}
return $this->db->transaction(function () use ($businessTrip) {
// 删除行程明细
BusinessTripItinerary::where('business_trip_id', $businessTrip->id)->delete();
// 删除申请
return $businessTrip->delete();
});
}
/**
* 审批操作
*/
public function approve(int $userId, int $businessTripId, string $action, string $comment = ''): BusinessTrip
{
return $this->db->transaction(function () use ($userId, $businessTripId, $action, $comment) {
$businessTrip = BusinessTrip::with(['itineraries', 'approvalOrder'])->findOrFail($businessTripId);
if (!$businessTrip->approval_order_id) {
throw new \Exception('未找到审批单');
}
$approvalOrder = $businessTrip->approvalOrder;
if ($approvalOrder->status !== ApprovalOrder::STATUS_PENDING) {
throw new \Exception('当前状态无法审批');
}
// 检查用户是否是当前审批人
$currentNodes = $approvalOrder->current_nodes ?? [];
$isApprover = false;
foreach ($currentNodes as $nodeId) {
$node = ApprovalNode::where('order_id', $approvalOrder->id)
->where('id', $nodeId)
->first();
if (!$node) {
continue;
}
// 检查是否是单人审批节点
if ($node->node_type === 'single_approve') {
$approvers = $node->approvers ?? [];
if (in_array($userId, $approvers)) {
$isApprover = true;
break;
}
}
}
if (!$isApprover) {
throw new \Exception('您不是当前审批人');
}
// 检查是否已经审批过
$existingRecord = ApprovalRecord::where('order_id', $approvalOrder->id)
->where('approver_id', $userId)
->whereIn('action', ['approve', 'reject'])
->first();
if ($existingRecord) {
throw new \Exception('您已经审批过了');
}
// 记录审批操作
ApprovalRecord::create([
'order_id' => $approvalOrder->id,
'node_id' => $currentNodes[0] ?? 1,
'node_type' => 'single_approve',
'approver_id' => $userId,
'action' => $action,
'comment' => $comment,
]);
// 更新审批单状态
if ($action === 'reject') {
// 拒绝:直接结束流程
$approvalOrder->status = ApprovalOrder::STATUS_REJECTED;
$approvalOrder->current_nodes = [];
$businessTrip->status = BusinessTrip::STATUS_REJECTED;
} else {
// 同意:检查是否还有后续节点(简化版:直接通过)
$approvalOrder->status = ApprovalOrder::STATUS_APPROVED;
$approvalOrder->current_nodes = [];
$businessTrip->status = BusinessTrip::STATUS_APPROVED;
}
$approvalOrder->save();
$businessTrip->save();
return $businessTrip->fresh(['itineraries', 'approvalOrder']);
});
}
}
@@ -1,11 +1,11 @@
<?php
namespace App\Service;
namespace app\service;
use App\Model\Company;
use App\Model\CompanyFeishuApp;
use App\Model\User;
use App\Util\Db;
use app\model\Company;
use app\model\CompanyFeishuApp;
use app\model\User;
use app\util\Db;
class FeishuService
{
@@ -1,6 +1,6 @@
<?php
namespace App\Util;
namespace app\util;
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Events\Dispatcher;
@@ -1,6 +1,6 @@
<?php
namespace App\Util;
namespace app\util;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
@@ -1,6 +1,6 @@
<?php
namespace App\Util;
namespace app\util;
/**
* 多语言翻译类
+1 -1
View File
@@ -19,7 +19,7 @@
},
"autoload": {
"psr-4": {
"App\\": "app/"
"app\\": "app/"
}
},
"scripts": {
-5
View File
@@ -1,5 +0,0 @@
<?php
return [
App\Bootstrap\Database::class,
];
+33 -32
View File
@@ -2,41 +2,42 @@
use Webman\Route;
// API 路由
// 认证路由(不需要认证)
Route::group('/api/v1', function () {
// 认证路由(不需要认证)
Route::post('/auth/login', [App\Controller\AuthController::class, 'login']);
Route::post('/auth/feishu-login', [App\Controller\AuthController::class, 'feishuLogin']);
// 用户路由(需要认证)
Route::group(function () {
Route::get('/user/info', [App\Controller\UserController::class, 'info']);
Route::get('/user/menu', [App\Controller\UserController::class, 'menu']);
Route::get('/user/companies', [App\Controller\UserController::class, 'companies']);
Route::get('/user/list', [App\Controller\UserController::class, 'list']);
Route::post('/user/switch-company', [App\Controller\UserController::class, 'switchCompany']);
});
// 审批路由(需要认证)
Route::group(function () {
Route::get('/approval/list', [App\Controller\ApprovalController::class, 'list']);
Route::get('/approval/detail/{id}', [App\Controller\ApprovalController::class, 'detail']);
Route::post('/approval/approve/{id}', [App\Controller\ApprovalController::class, 'approve']);
Route::post('/approval/reject/{id}', [App\Controller\ApprovalController::class, 'reject']);
Route::post('/approval/cancel/{id}', [App\Controller\ApprovalController::class, 'cancel']);
});
// 出差审批路由(需要认证)
Route::group(function () {
Route::get('/business-trip/list', [App\Controller\BusinessTripController::class, 'list']);
Route::get('/business-trip/detail/{id}', [App\Controller\BusinessTripController::class, 'detail']);
Route::post('/business-trip/create', [App\Controller\BusinessTripController::class, 'create']);
Route::post('/business-trip/update/{id}', [App\Controller\BusinessTripController::class, 'update']);
Route::post('/business-trip/submit/{id}', [App\Controller\BusinessTripController::class, 'submit']);
Route::post('/business-trip/cancel/{id}', [App\Controller\BusinessTripController::class, 'cancel']);
});
Route::post('/auth/login', [app\controller\AuthController::class, 'login']);
Route::post('/auth/feishu-login', [app\controller\AuthController::class, 'feishuLogin']);
});
// 用户路由(需要认证)
Route::group('/api/v1/user', function () {
Route::get('/info', [app\controller\UserController::class, 'info']);
Route::get('/menu', [app\controller\UserController::class, 'menu']);
Route::get('/companies', [app\controller\UserController::class, 'companies']);
Route::get('/list', [app\controller\UserController::class, 'list']);
Route::post('/switch-company', [app\controller\UserController::class, 'switchCompany']);
})->middleware([\app\middleware\AuthMiddleware::class]);
// 审批路由(需要认证)
Route::group('/api/v1/approval', function () {
Route::get('/list', [app\controller\ApprovalController::class, 'list']);
Route::get('/detail/{id}', [app\controller\ApprovalController::class, 'detail']);
Route::post('/approve/{id}', [app\controller\ApprovalController::class, 'approve']);
Route::post('/reject/{id}', [app\controller\ApprovalController::class, 'reject']);
Route::post('/cancel/{id}', [app\controller\ApprovalController::class, 'cancel']);
})->middleware([\app\middleware\AuthMiddleware::class]);
// 出差审批路由(需要认证)
Route::group('/api/v1/business-trip', function () {
Route::get('/list', [app\controller\BusinessTripController::class, 'list']);
Route::get('/detail/{id}', [app\controller\BusinessTripController::class, 'detail']);
Route::post('/create', [app\controller\BusinessTripController::class, 'create']);
Route::post('/update/{id}', [app\controller\BusinessTripController::class, 'update']);
Route::post('/submit/{id}', [app\controller\BusinessTripController::class, 'submit']);
Route::post('/cancel/{id}', [app\controller\BusinessTripController::class, 'cancel']);
Route::delete('/{id}', [app\controller\BusinessTripController::class, 'deleteDraft']);
Route::post('/approve/{id}', [app\controller\BusinessTripController::class, 'approve']);
})->middleware([\app\middleware\AuthMiddleware::class]);
// 默认路由
Route::fallback(function () {
return json(['code' => 404, 'message' => 'Not Found']);
+1 -1
View File
@@ -11,7 +11,7 @@
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../app/Util/Db.php';
use App\Util\Db;
use app\util\Db;
class DataImporter
{
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Token 设置工具</title>
<style>
body { font-family: sans-serif; padding: 20px; max-width: 600px; margin: 0 auto; }
h1 { color: #333; }
.section { background: #f5f5f5; padding: 20px; margin: 20px 0; border-radius: 8px; }
button { background: #1677ff; color: white; border: none; padding: 12px 30px; font-size: 16px; border-radius: 6px; cursor: pointer; }
button:hover { background: #0958d9; }
.success { color: #52c41a; font-weight: bold; margin-top: 10px; }
.token-display { background: #fff; padding: 10px; border-radius: 4px; word-break: break-all; font-family: monospace; font-size: 12px; margin-top: 10px; }
</style>
</head>
<body>
<h1>🔧 开发调试工具</h1>
<div class="section">
<h3>步骤 1:复制 Token</h3>
<div class="token-display" id="tokenDisplay"></div>
<button onclick="copyToken()">复制 Token</button>
<span id="copyResult"></span>
</div>
<div class="section">
<h3>步骤 2:设置 Token</h3>
<ol>
<li>按 F12 打开开发者工具</li>
<li>切换到 Console 标签</li>
<li>粘贴并执行:<br><code>localStorage.setItem('token', 刚才复制的token)</code></li>
</ol>
</div>
<div class="section">
<h3>步骤 3:验证</h3>
<button onclick="verifyToken()">验证 Token 是否设置成功</button>
<div id="verifyResult" style="margin-top: 10px;"></div>
</div>
<script>
const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJjb21wYW55X2lkIjoyLCJ1c2VybmFtZSI6Ilx1Njc0ZVx1NGY3M1x1NTM1YSIsImlhdCI6MTc3MjAwOTA0MCwiZXhwIjoxNzcyNjEzODQwfQ.mFsGM4bxRZdAMTQ3QgHO-DoFCWZUhUNY2N62VkN6FZ4';
document.getElementById('tokenDisplay').textContent = token;
function copyToken() {
navigator.clipboard.writeText(token).then(() => {
document.getElementById('copyResult').textContent = ' ✅ 已复制!';
}).catch(() => {
// 备用方案
const textarea = document.createElement('textarea');
textarea.value = token;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
document.getElementById('copyResult').textContent = ' ✅ 已复制!';
});
}
function verifyToken() {
const saved = localStorage.getItem('token');
const resultDiv = document.getElementById('verifyResult');
if (saved === token) {
resultDiv.innerHTML = '<span class="success">✅ Token 验证成功!可以刷新主页面了。</span>';
} else if (saved) {
resultDiv.innerHTML = '<span style="color: orange;">⚠️ Token 已存在但不匹配,请重新设置</span>';
} else {
resultDiv.innerHTML = '<span style="color: red;">✗ Token 未设置,请按照步骤2设置</span>';
}
}
</script>
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 Token</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
text-align: center;
max-width: 400px;
}
h1 {
color: #333;
margin-bottom: 20px;
}
.info {
color: #666;
margin-bottom: 30px;
line-height: 1.6;
}
button {
background: #667eea;
color: white;
border: none;
padding: 15px 40px;
font-size: 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
button:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.success {
color: #52c41a;
font-size: 18px;
font-weight: bold;
margin-top: 20px;
}
.user-info {
background: #f5f5f5;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
text-align: left;
font-size: 14px;
}
.user-info div {
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>🎯 设置登录 Token</h1>
<div class="info">
点击下方按钮设置登录凭证,然后关闭此页面并刷新主页面。
</div>
<button id="setTokenBtn">设置 Token</button>
<div id="result"></div>
</div>
<script>
document.getElementById('setTokenBtn').addEventListener('click', function() {
const token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJjb21wYW55X2lkIjoyLCJ1c2VybmFtZSI6Ilx1Njc0ZVx1NGY3M1x1NTM1YSIsImlhdCI6MTc3MjAwOTA0MCwiZXhwIjoxNzcyNjEzODQwfQ.mFsGM4bxRZdAMTQ3QgHO-DoFCWZUhUNY2N62VkN6FZ4';
localStorage.setItem('token', token);
const resultDiv = document.getElementById('result');
resultDiv.innerHTML = `
<div class="success">✅ Token 设置成功!</div>
<div class="user-info">
<div><strong>用户:</strong>李佳博</div>
<div><strong>邮箱:</strong>mzl@hnxinyouhe.com</div>
<div><strong>有效期至:</strong>2025-11-02</div>
</div>
<div style="margin-top: 20px; color: #999; font-size: 12px;">
现在可以关闭此页面,然后刷新主页面继续使用。
</div>
};
document.getElementById('setTokenBtn').style.display = 'none';
});
</script>
</body>
</html>
@@ -10,7 +10,7 @@ export default function MobileLayout() {
const mainRef = useRef<HTMLDivElement>(null)
const showHeader = location.pathname !== '/login'
const showTabBar = !location.pathname.includes('/business-trip/')
const showTabBar = false // 暂时隐藏底部 Tab
// 路由变化时滚动到顶部
useEffect(() => {
+1 -2
View File
@@ -1,5 +1,5 @@
import { useNavigate, useLocation } from 'react-router-dom'
import { AppOutline, AppstoreOutline, UserOutline } from 'antd-mobile-icons'
import { AppOutline, AppstoreOutline } from 'antd-mobile-icons'
import { useTranslation } from 'react-i18next'
import styles from './TabBar.module.css'
@@ -13,7 +13,6 @@ interface TabItem {
const tabs: TabItem[] = [
{ key: 'home', path: '/dashboard', icon: <AppOutline />, labelKey: 'tab.home' },
{ key: 'apps', path: '/apps', icon: <AppstoreOutline />, labelKey: 'tab.apps' },
{ key: 'profile', path: '/profile', icon: <UserOutline />, labelKey: 'tab.profile' },
]
export default function TabBar() {
+52
View File
@@ -68,3 +68,55 @@ select {
padding-bottom: env(safe-area-inset-bottom);
}
}
/* Dialog 组件层级修复 - 确保在所有固定定位元素之上 */
/* Dialog 容器 - 最高优先级 */
.adm-dialog {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
z-index: 99999 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Dialog 遮罩 - 确保在容器内但有正确的层级 */
.adm-dialog .adm-dialog-mask {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
z-index: 1 !important;
background-color: rgba(0, 0, 0, 0.45) !important;
}
/* Dialog 内容容器 - 在遮罩之上 */
.adm-dialog .adm-dialog-content {
position: relative !important;
z-index: 2 !important;
}
/* Dialog 内部元素 - 确保在内容容器之上 */
.adm-dialog .adm-dialog-body,
.adm-dialog .adm-dialog-header,
.adm-dialog .adm-dialog-footer,
.adm-dialog .adm-button {
position: relative !important;
z-index: 3 !important;
}
/* 处理 antd-mobile 5.x 的实际 DOM 结构 */
/* antd-mobile 5.x 的 Dialog 可能直接渲染到 body 下 */
body > .adm-dialog {
z-index: 99999 !important;
}
/* 确保 mask 不会覆盖 dialog 内容 */
.adm-dialog-mask ~ .adm-dialog-content {
position: relative !important;
z-index: 10 !important;
}
+1
View File
@@ -8,6 +8,7 @@ import 'antd-mobile/es/global'
import 'antd-mobile/es/components/popup/popup.css'
import 'antd-mobile/es/components/mask/mask.css'
import 'antd-mobile/es/components/date-picker/date-picker.css'
import 'antd-mobile/es/components/dialog/dialog.css'
import router from './router'
import { initRem } from './utils/rem'
import './i18n'
@@ -155,10 +155,13 @@
z-index: 10000 !important;
}
:global(.adm-mask) {
/* 只影响 Popup 的 mask,不影响 Dialog */
:global(.adm-popup .adm-mask) {
z-index: 9999 !important;
}
/* Dialog 样式在 index.css 中统一设置 */
/* Selector 组件样式覆盖 */
.field :global(.adm-selector) {
flex: 1;
@@ -407,7 +410,7 @@
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
display: flex;
gap: 12px;
z-index: 9999;
z-index: 100;
}
.saveBtn,
+158 -21
View File
@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { DatePicker, Input, Toast, Dialog, TextArea, Selector } from 'antd-mobile'
@@ -65,31 +65,118 @@ export default function BusinessTripApply() {
const [approvers, setApprovers] = useState<ApproverItem[]>([])
const [ccUsers, setCcUsers] = useState<ApproverItem[]>([])
// 手动保存草稿的函数
const saveDraft = () => {
const draftData = {
reason,
itineraries,
}
sessionStorage.setItem('businessTripDraft', JSON.stringify(draftData))
console.log('💾 Manually saved draft:', { reason, itineraries })
}
// DatePicker 可见性状态
const [datePickerVisible, setDatePickerVisible] = useState<{
index: number
field: 'startTime' | 'endTime'
} | null>(null)
// 处理从选择页面返回的数据
// 使用 ref 来跟踪是否已经恢复过草稿
const hasRestoredRef = useRef(false)
// 处理从选择页面返回的数据和草稿恢复
useEffect(() => {
// 检查 sessionStorage 中是否有选择的数据
const approversData = sessionStorage.getItem('selectedApprovers')
const ccData = sessionStorage.getItem('selectedCc')
const fromSelection = location.state?.fromSelection
// 从 sessionStorage 恢复状态(组件重新挂载时需要)
if (approversData) {
const approvers = JSON.parse(approversData) as ApproverItem[]
setApprovers(approvers)
console.log('✅ Restored approvers from sessionStorage:', approvers)
console.log('📍 Page loaded/updated:', {
fromSelection,
path: location.pathname
})
// 恢复草稿数据(每次组件挂载都需要恢复)
const draftData = sessionStorage.getItem('businessTripDraft')
console.log('📦 Draft data in sessionStorage:', draftData)
if (draftData) {
try {
const draft = JSON.parse(draftData)
console.log('📄 Parsed draft:', draft)
// 恢复理由
if (draft.reason) {
console.log('✏️ Restoring reason:', draft.reason)
setReason(draft.reason)
}
// 恢复行程数据
if (draft.itineraries && draft.itineraries.length > 0) {
const restoredItineraries = draft.itineraries.map((item: any) => ({
...item,
startTime: item.startTime ? new Date(item.startTime) : null,
endTime: item.endTime ? new Date(item.endTime) : null,
}))
console.log('🛤️ Restoring itineraries:', restoredItineraries)
setItineraries(restoredItineraries)
}
// 有草稿数据,也尝试恢复人员数据
if (!fromSelection) {
const approversData = sessionStorage.getItem('selectedApprovers')
if (approversData) {
const approvers = JSON.parse(approversData) as ApproverItem[]
setApprovers(approvers)
console.log('✅ Restored approvers (with draft):', approvers)
}
const ccData = sessionStorage.getItem('selectedCc')
if (ccData) {
const cc = JSON.parse(ccData) as ApproverItem[]
setCcUsers(cc)
console.log('✅ Restored CC (with draft):', cc)
}
}
} catch (error) {
console.error('❌ Failed to restore draft:', error)
}
} else {
console.log('⚠️ No draft data found in sessionStorage')
// 没有草稿数据,清除旧的人员选择数据(开始新申请)
if (!fromSelection) {
console.log('🧹 Clearing old selection data (new application)')
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
setApprovers([])
setCcUsers([])
}
}
if (ccData) {
const cc = JSON.parse(ccData) as ApproverItem[]
setCcUsers(cc)
console.log('✅ Restored CC from sessionStorage:', cc)
// 从选择页面返回,恢复所有人员数据
if (fromSelection) {
console.log('🔄 Returning from selection page')
const type = location.state?.type
console.log('📋 Selection type:', type)
// 恢复所有人员数据(审批人 + 抄送人)
const approversData = sessionStorage.getItem('selectedApprovers')
if (approversData) {
const approvers = JSON.parse(approversData) as ApproverItem[]
setApprovers(approvers)
console.log('✅ Restored approvers from sessionStorage:', approvers)
} else {
setApprovers([])
}
const ccData = sessionStorage.getItem('selectedCc')
if (ccData) {
const cc = JSON.parse(ccData) as ApproverItem[]
setCcUsers(cc)
console.log('✅ Restored CC from sessionStorage:', cc)
} else {
setCcUsers([])
}
}
}, []) // 只在组件挂载时执行一次
}, [location.state, location.pathname])
// 计算总时长(累加所有行程的天数)
const totalDuration = useMemo(() => {
@@ -102,7 +189,7 @@ export default function BusinessTripApply() {
}, [itineraries])
const handleAddItinerary = () => {
setItineraries([...itineraries, {
const newItineraries = [...itineraries, {
startTime: null,
endTime: null,
departure: '',
@@ -110,7 +197,9 @@ export default function BusinessTripApply() {
transport: '',
tripType: '' as 'oneway' | 'roundtrip' | '',
remark: ''
}])
}]
setItineraries(newItineraries)
saveDraft()
}
const handleRemoveItinerary = (index: number) => {
@@ -118,24 +207,54 @@ export default function BusinessTripApply() {
Toast.show('至少保留一条行程')
return
}
setItineraries(itineraries.filter((_, i) => i !== index))
const newItineraries = itineraries.filter((_, i) => i !== index)
setItineraries(newItineraries)
saveDraft()
}
const handleUpdateItinerary = (index: number, field: keyof ItineraryItem, value: any) => {
const updated = [...itineraries]
updated[index] = { ...updated[index], [field]: value }
setItineraries(updated)
saveDraft()
}
const handleSelectApprovers = () => {
console.log('🚀 Navigating to select approvers')
saveDraft() // 导航前保存草稿
// 如果草稿中没有实际内容(空理由且没有有效行程),清除旧的人员数据
const hasContent = reason.trim() !== '' || itineraries.some(item => item.startTime || item.destination.trim() !== '')
if (!hasContent) {
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
console.log('🧹 Cleared old selection data (no content)')
}
navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } })
}
const handleSelectCc = () => {
console.log('🚀 Navigating to select CC')
saveDraft() // 导航前保存草稿
// 如果草稿中没有实际内容(空理由且没有有效行程),清除旧的人员数据
const hasContent = reason.trim() !== '' || itineraries.some(item => item.startTime || item.destination.trim() !== '')
if (!hasContent) {
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
console.log('🧹 Cleared old selection data (no content)')
}
navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } })
}
const handleSave = async () => {
// 移除当前焦点,避免按钮保持高亮状态
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
try {
// 验证必填字段
if (!reason.trim()) {
@@ -170,6 +289,7 @@ export default function BusinessTripApply() {
// 保存成功后清除 sessionStorage
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
sessionStorage.removeItem('businessTripDraft')
Toast.show('草稿已保存')
navigate('/business-trip')
@@ -179,6 +299,11 @@ export default function BusinessTripApply() {
}
const handleSubmit = () => {
// 移除当前焦点,避免按钮保持高亮状态
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
if (!reason.trim()) {
Toast.show(t('businessTrip.pleaseEnterReason'))
return
@@ -197,6 +322,8 @@ export default function BusinessTripApply() {
Dialog.confirm({
content: t('businessTrip.confirmSubmit'),
confirmText: '确认',
cancelText: '取消',
onConfirm: async () => {
try {
// 准备行程数据
@@ -228,6 +355,7 @@ export default function BusinessTripApply() {
// 提交成功后清除 sessionStorage
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
sessionStorage.removeItem('businessTripDraft')
Toast.show(t('businessTrip.submitSuccess'))
navigate('/business-trip')
@@ -248,7 +376,10 @@ export default function BusinessTripApply() {
<TextArea
placeholder={t('businessTrip.pleaseEnterReason')}
value={reason}
onChange={setReason}
onChange={(val) => {
setReason(val)
saveDraft()
}}
rows={3}
maxLength={500}
/>
@@ -292,8 +423,11 @@ export default function BusinessTripApply() {
<DatePicker
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'startTime'}
onConfirm={(val) => {
handleUpdateItinerary(index, 'startTime', val)
const updated = [...itineraries]
updated[index] = { ...updated[index], startTime: val }
setItineraries(updated)
setDatePickerVisible(null)
saveDraft()
}}
onClose={() => setDatePickerVisible(null)}
onCancel={() => setDatePickerVisible(null)}
@@ -312,8 +446,11 @@ export default function BusinessTripApply() {
<DatePicker
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'endTime'}
onConfirm={(val) => {
handleUpdateItinerary(index, 'endTime', val)
const updated = [...itineraries]
updated[index] = { ...updated[index], endTime: val }
setItineraries(updated)
setDatePickerVisible(null)
saveDraft()
}}
onClose={() => setDatePickerVisible(null)}
onCancel={() => setDatePickerVisible(null)}
@@ -60,6 +60,37 @@
flex: 1;
}
.userList {
display: flex;
margin-bottom: 12px;
}
.userList:last-child {
margin-bottom: 0;
}
.userListLabel {
color: #999;
font-size: 14px;
width: 80px;
flex-shrink: 0;
}
.users {
display: flex;
flex-wrap: wrap;
gap: 8px;
flex: 1;
}
.user {
background: #f5f5f5;
padding: 4px 12px;
border-radius: 16px;
font-size: 13px;
color: #333;
}
.section {
margin-bottom: 16px;
}
@@ -94,11 +125,77 @@
}
.timeline {
padding: 16px 0;
padding: 8px 0;
}
.timelineItem {
position: relative;
padding-left: 40px;
padding-bottom: 20px;
}
.timelineItem:last-child {
padding-bottom: 0;
}
.timelineItem:not(:last-child)::before {
content: '';
position: absolute;
left: 11px;
top: 24px;
bottom: 0;
width: 2px;
background: #e8e8e8;
}
.timelineDot {
position: absolute;
left: 0;
top: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background: #1677ff;
display: flex;
align-items: center;
justify-content: center;
}
.timelineDotIcon {
font-size: 12px;
}
.timelineContent {
padding: 4px 0;
}
.timelineTitle {
font-weight: 500;
color: #333;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.timelineName {
font-size: 13px;
color: #666;
font-weight: 400;
}
.timelineTime {
font-size: 12px;
color: #999;
}
.timelineComment {
font-size: 13px;
color: #666;
margin-top: 4px;
background: #f5f5f5;
padding: 6px 10px;
border-radius: 4px;
}
.timelineTitle {
@@ -152,6 +249,20 @@
flex: 1;
}
.deleteBtn {
flex: 0 0 auto;
min-width: 120px;
}
.commentBox {
padding: 8px 0;
}
.commentBox :global(.adm-text-area) {
background: #f5f5f5;
border-radius: 8px;
}
:global(.adm-card-body) {
padding: 16px;
}
+211 -64
View File
@@ -1,9 +1,17 @@
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { NavBar, Card, Tag, Button, Dialog, Toast } from 'antd-mobile'
import { LeftOutline, ClockCircleOutline, CheckCircleOutline, CloseCircleOutline, ExclamationCircleOutline } from 'antd-mobile-icons'
import { businessTripApi, approvalApi } from '../../services/api'
import { NavBar, Card, Tag, Button, Dialog, Toast, TextArea } from 'antd-mobile'
import {
LeftOutline,
ClockCircleOutline,
CheckCircleOutline,
CloseCircleOutline,
ExclamationCircleOutline,
DeleteOutline
} from 'antd-mobile-icons'
import { businessTripApi, userApi } from '../../services/api'
import { useUserStore } from '@/stores/user'
import dayjs from 'dayjs'
import styles from './detail.module.css'
@@ -27,6 +35,17 @@ interface Itinerary {
remark: string
}
interface ApprovalRecord {
id: number
approver_id: number
action: string
comment: string | null
created_at: string
approver: {
name: string
}
}
interface BusinessTripDetail {
id: number
reason: string
@@ -42,15 +61,30 @@ interface BusinessTripDetail {
email: string
}
itineraries: Itinerary[]
approval_order?: {
id: number
status: string
form_data: {
approvers?: any[]
cc_users?: any[]
}
}
approvers?: any[]
cc_users?: any[]
approval_records?: ApprovalRecord[]
}
export default function BusinessTripDetail() {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams()
const { user } = useUserStore()
const [detail, setDetail] = useState<BusinessTripDetail | null>(null)
const [loading, setLoading] = useState(true)
const [commentVisible, setCommentVisible] = useState(false)
const [comment, setComment] = useState('')
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve')
// 加载详情
const loadDetail = async () => {
@@ -72,6 +106,24 @@ export default function BusinessTripDetail() {
loadDetail()
}, [id])
// 删除草稿
const handleDelete = () => {
Dialog.confirm({
content: '确定要删除此草稿吗?删除后无法恢复',
confirmText: '删除',
cancelText: '取消',
onConfirm: async () => {
try {
await businessTripApi.deleteDraft(Number(id))
Toast.show('删除成功')
navigate('/business-trip')
} catch (error: any) {
Toast.show(error.message || '删除失败')
}
},
})
}
// 撤销申请
const handleCancel = () => {
Dialog.confirm({
@@ -93,13 +145,39 @@ export default function BusinessTripDetail() {
navigate(`/business-trip/apply?id=${id}`)
}
// 打开审批意见框
const handleApprove = (action: 'approve' | 'reject') => {
setActionType(action)
setComment('')
setCommentVisible(true)
}
// 提交审批
const handleConfirmApprove = async () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
try {
await businessTripApi.approve(Number(id), actionType, comment)
Toast.show(actionType === 'approve' ? '已同意' : '已拒绝')
setCommentVisible(false)
loadDetail()
} catch (error: any) {
Toast.show(error.message || '操作失败')
}
}
if (loading || !detail) {
return <div className={styles.loading}>加载中...</div>
}
const config = statusConfig[detail.status] || statusConfig.draft
const canEdit = detail.status === 'draft'
const canCancel = detail.status === 'pending'
const isOwner = detail.user?.name === user?.name
const canEdit = detail.status === 'draft' && isOwner
const canDelete = detail.status === 'draft' && isOwner
const canCancel = detail.status === 'pending' && isOwner
const canApprove = detail.status === 'pending' && !isOwner
// 计算天数
const calculateDays = (start: string, end: string) => {
@@ -119,6 +197,22 @@ export default function BusinessTripDetail() {
return dayjs(dateStr).format('YYYY-MM-DD')
}
// 获取审批记录配置
const getRecordConfig = (action: string) => {
switch (action) {
case 'submit':
return { color: '#999', icon: '📝', text: '提交申请' }
case 'approve':
return { color: '#52c41a', icon: '✅', text: '已同意' }
case 'reject':
return { color: '#ff4d4f', icon: '❌', text: '已拒绝' }
case 'cancel':
return { color: '#999', icon: '🔄', text: '已撤销' }
default:
return { color: '#999', icon: '📌', text: action }
}
}
return (
<div className={styles.detail}>
<NavBar onBack={() => navigate(-1)}>出差申请详情</NavBar>
@@ -138,10 +232,6 @@ export default function BusinessTripDetail() {
<span className={styles.label}>申请人:</span>
<span className={styles.value}>{detail.user?.name}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.label}>联系电话:</span>
<span className={styles.value}>{detail.user?.phone}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.label}>出发日期:</span>
<span className={styles.value}>{formatDate(detail.start_date)}</span>
@@ -158,15 +248,39 @@ export default function BusinessTripDetail() {
<span className={styles.label}>目的地:</span>
<span className={styles.value}>{detail.destination}</span>
</div>
{detail.estimated_cost && detail.estimated_cost !== '0.00' && (
<div className={styles.infoItem}>
<span className={styles.label}>预估费用:</span>
<span className={styles.value}>¥{detail.estimated_cost}</span>
</div>
)}
</div>
</Card>
{/* 审批人和抄送人 */}
{(detail.approvers?.length || detail.cc_users?.length) && (
<Card title="审批信息" className={styles.section}>
{detail.approvers && detail.approvers.length > 0 && (
<div className={styles.userList}>
<div className={styles.userListLabel}>审批人:</div>
<div className={styles.users}>
{detail.approvers.map((u: any) => (
<div key={u.id} className={styles.user}>
<span>{u.name}</span>
</div>
))}
</div>
</div>
)}
{detail.cc_users && detail.cc_users.length > 0 && (
<div className={styles.userList}>
<div className={styles.userListLabel}>抄送人:</div>
<div className={styles.users}>
{detail.cc_users.map((u: any) => (
<div key={u.id} className={styles.user}>
<span>{u.name}</span>
</div>
))}
</div>
</div>
)}
</Card>
)}
{/* 行程明细 */}
<Card title="行程明细" className={styles.section}>
{detail.itineraries && detail.itineraries.length > 0 ? (
@@ -216,63 +330,55 @@ export default function BusinessTripDetail() {
)}
</Card>
{/* 审批进度 */}
<Card title="审批进度" className={styles.section}>
{/* 审批记录 */}
<Card title="审批记录" className={styles.section}>
<div className={styles.timeline}>
<div className={styles.timelineItem}>
<div className={styles.timelineDot}></div>
<div className={styles.timelineContent}>
<div className={styles.timelineTitle}>提交申请</div>
<div className={styles.timelineTime}>
{dayjs(detail.created_at).format('YYYY-MM-DD HH:mm')}
</div>
</div>
</div>
{detail.status === 'pending' && (
<div className={styles.timelineItem}>
<div className={styles.timelineDotPending}></div>
<div className={styles.timelineContent}>
<div className={styles.timelineTitle}>等待审批中...</div>
<div className={styles.timelineDesc}>审批人将收到通知并处理您的申请</div>
</div>
</div>
)}
{detail.status === 'approved' && (
<div className={styles.timelineItem}>
<div className={styles.timelineDotSuccess}>
<CheckCircleOutline className={styles.timelineIconSuccess} />
</div>
<div className={styles.timelineContent}>
<div className={styles.timelineTitle}>审批已通过</div>
</div>
</div>
)}
{detail.status === 'rejected' && (
<div className={styles.timelineItem}>
<div className={styles.timelineDotError}>
<CloseCircleOutline className={styles.timelineIconError} />
</div>
<div className={styles.timelineContent}>
<div className={styles.timelineTitle}>审批已拒绝</div>
</div>
</div>
)}
{detail.status === 'cancelled' && (
<div className={styles.timelineItem}>
<div className={styles.timelineDotCancelled}></div>
<div className={styles.timelineContent}>
<div className={styles.timelineTitle}>已撤销</div>
<div className={styles.timelineDesc}>申请人撤销了此申请</div>
</div>
</div>
{detail.approval_records && detail.approval_records.length > 0 ? (
detail.approval_records.map((record) => {
const recordConfig = getRecordConfig(record.action)
return (
<div key={record.id} className={styles.timelineItem}>
<div className={styles.timelineDot} style={{ background: recordConfig.color }}>
<span className={styles.timelineDotIcon}>{recordConfig.icon}</span>
</div>
<div className={styles.timelineContent}>
<div className={styles.timelineTitle}>
{recordConfig.text}
<span className={styles.timelineName}>
{record.approver?.name}
</span>
</div>
<div className={styles.timelineTime}>
{dayjs(record.created_at).format('YYYY-MM-DD HH:mm')}
</div>
{record.comment && (
<div className={styles.timelineComment}>备注: {record.comment}</div>
)}
</div>
</div>
)
})
) : (
<div className={styles.empty}>暂无审批记录</div>
)}
</div>
</Card>
</div>
{/* 底部操作栏 */}
{(canEdit || canCancel) && (
{(canEdit || canDelete || canCancel || canApprove) && (
<div className={styles.footer}>
{canDelete && (
<Button
block
color="danger"
fill="outline"
onClick={handleDelete}
className={styles.deleteBtn}
>
<DeleteOutline /> 删除草稿
</Button>
)}
{canEdit && (
<Button block color="primary" onClick={handleEdit}>
编辑申请
@@ -283,8 +389,49 @@ export default function BusinessTripDetail() {
撤销申请
</Button>
)}
{canApprove && (
<>
<Button
block
color="danger"
fill="outline"
onClick={() => handleApprove('reject')}
>
拒绝
</Button>
<Button
block
color="primary"
onClick={() => handleApprove('approve')}
>
同意
</Button>
</>
)}
</div>
)}
{/* 审批意见弹框 */}
<Dialog
visible={commentVisible}
title={actionType === 'approve' ? '同意申请' : '拒绝申请'}
content={
<div className={styles.commentBox}>
<TextArea
placeholder="请输入审批意见(可选)"
value={comment}
onChange={setComment}
rows={3}
maxLength={200}
/>
</div>
}
onConfirm={handleConfirmApprove}
onClose={() => setCommentVisible(false)}
onCancel={() => setCommentVisible(false)}
confirmText="确认"
cancelText="取消"
/>
</div>
)
}
@@ -5,7 +5,7 @@
}
.item {
padding: 12px 16px !important;
padding: 0 12px !important;
}
.card {
@@ -14,11 +14,15 @@
overflow: hidden;
}
.card :global(.adm-card-body) {
padding: 8px 12px !important;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
margin-bottom: 6px;
}
.reason {
@@ -32,7 +36,7 @@
.content {
display: flex;
flex-direction: column;
gap: 8px;
gap: 4px;
}
.info {
@@ -34,17 +34,10 @@ export default function SelectUserPage() {
const state = location.state as LocationState
const [searchValue, setSearchValue] = useState('')
// 优先从 location.state 获取,否则从 sessionStorage 获取
// 只从 sessionStorage 获取对应类型的数据
const getInitialSelected = () => {
if (state?.selected) return state.selected
if (state?.type === 'approver') {
const data = sessionStorage.getItem('selectedApprovers')
return data ? JSON.parse(data) : []
} else if (state?.type === 'cc') {
const data = sessionStorage.getItem('selectedCc')
return data ? JSON.parse(data) : []
}
return []
const data = sessionStorage.getItem('selectedApprovers')
return data ? JSON.parse(data) : []
}
const [selectedUsers, setSelectedUsers] = useState<UserItem[]>(getInitialSelected())
const [allUsers, setAllUsers] = useState<UserItem[]>([])
@@ -118,7 +111,8 @@ export default function SelectUserPage() {
} else if (type === 'cc') {
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
}
navigate(-1)
// 使用 navigate 而不是 navigate(-1),传递标志表示从选择页面返回
navigate('/business-trip/apply', { state: { fromSelection: true, type } })
}
const handleAddApprover = () => {
@@ -34,9 +34,8 @@ export default function SelectCcPage() {
const state = location.state as LocationState
const [searchValue, setSearchValue] = useState('')
// 优先从 location.state 获取,否则从 sessionStorage 获取
// 只从 sessionStorage 获取对应类型的数据
const getInitialSelected = () => {
if (state?.selected) return state.selected
const data = sessionStorage.getItem('selectedCc')
return data ? JSON.parse(data) : []
}
@@ -107,7 +106,8 @@ export default function SelectCcPage() {
const handleConfirm = () => {
// 使用 sessionStorage 来传递数据
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
navigate(-1)
// 使用 navigate 而不是 navigate(-1),传递标志表示从选择页面返回
navigate('/business-trip/apply', { state: { fromSelection: true, type: 'cc' } })
}
return (
+41 -91
View File
@@ -1,6 +1,7 @@
import { useNavigate } from 'react-router-dom'
import { useUserStore } from '@/stores/user'
import { useTranslation } from 'react-i18next'
import { Toast } from 'antd-mobile'
import { showLanguageSwitcher } from '@/components/LanguageSwitcher'
import styles from './dashboard.module.css'
@@ -10,40 +11,23 @@ interface QuickAction {
label: string
path: string
color: string
description?: string
}
// 从应用页面迁移的完整应用列表
const quickActions: QuickAction[] = [
{ id: '1', icon: '✈️', label: '出差申请', path: '/business-trip/apply', color: '#1677ff' },
{ id: '2', icon: '📝', label: '采购申请', path: '/purchase/apply', color: '#52c41a' },
{ id: '3', icon: '🏠', label: '会议室', path: '/meeting-room', color: '#faad14' },
{ id: '4', icon: '📅', label: '日程管理', path: '/schedule', color: '#722ed1' },
{ id: '5', icon: '💰', label: '报销申请', path: '/expense/apply', color: '#eb2f96' },
{ id: '6', icon: '📋', label: '请假申请', path: '/leave/apply', color: '#13c2c2' },
]
interface TodoItem {
id: string
title: string
type: 'approval' | 'notice' | 'task'
time: string
}
const mockTodos: TodoItem[] = [
{ id: '1', title: '张三的出差申请待审批', type: 'approval', time: '10分钟前' },
{ id: '2', title: '本周部门会议', type: 'task', time: '今天 14:00' },
]
interface StatCard {
title: string
value: number
unit: string
color: string
}
const mockStats: StatCard[] = [
{ title: '待审批', value: 5, unit: '条', color: '#ff4d4f' },
{ title: '已审批', value: 12, unit: '条', color: '#52c41a' },
{ title: '我发起', value: 8, unit: '条', color: '#1677ff' },
{ id: '1', icon: '✈️', label: '出差申请', path: '/business-trip/apply', color: '#1677ff', description: '提交出差申请' },
{ id: '2', icon: '📝', label: '采购申请', path: '/purchase/apply', color: '#52c41a', description: '物品采购审批' },
{ id: '3', icon: '📦', label: '备货录入', path: '/stock/entry', color: '#faad14', description: '备货信息录入' },
{ id: '4', icon: '📅', label: '日程管理', path: '/schedule', color: '#722ed1', description: '个人日程安排' },
{ id: '5', icon: '💰', label: '报销申请', path: '/expense/apply', color: '#eb2f96', description: '费用报销审批' },
{ id: '6', icon: '📋', label: '请假申请', path: '/leave/apply', color: '#13c2c2', description: '请假审批' },
{ id: '7', icon: '🚗', label: '用车申请', path: '/car/apply', color: '#f759ab', description: '公车使用申请' },
{ id: '8', icon: '🎁', label: '礼品申请', path: '/gift/apply', color: '#ff7a45', description: '礼品采购申请' },
{ id: '9', icon: '📊', label: '数据报表', path: '/reports', color: '#1890ff', description: '业务数据统计' },
{ id: '10', icon: '👥', label: '通讯录', path: '/contacts', color: '#096dd9', description: '内部通讯录' },
{ id: '11', icon: '📁', label: '文件中心', path: '/files', color: '#595959', description: '文件共享管理' },
{ id: '12', icon: '⚙️', label: '系统设置', path: '/settings', color: '#8c8c8c', description: '系统偏好设置' },
]
export default function Dashboard() {
@@ -52,7 +36,13 @@ export default function Dashboard() {
const { t } = useTranslation()
const handleQuickAction = (action: QuickAction) => {
navigate(action.path)
// 允许的应用:出差申请、审批
const allowedPaths = ['/business-trip/apply', '/business-trip']
if (allowedPaths.includes(action.path)) {
navigate(action.path)
} else {
Toast.show({ content: '功能开发中...', duration: 1500 })
}
}
const getCurrentLanguage = () => {
@@ -83,59 +73,18 @@ export default function Dashboard() {
</button>
</div>
{/* 数据统计卡片 */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>数据概览</h3>
<div className={styles.statsGrid}>
{mockStats.map((stat) => (
<div key={stat.title} className={styles.statCard} style={{ borderTop: `3px solid ${stat.color}` }}>
<div className={styles.statValue}>{stat.value}</div>
<div className={styles.statTitle}>{stat.title}</div>
</div>
))}
</div>
</div>
{/* 待办事项 */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>待办事项</h3>
<span className={styles.more}>查看全部 {'>'}</span>
</div>
{mockTodos.length > 0 ? (
<div className={styles.todoList}>
{mockTodos.map((todo) => (
<div key={todo.id} className={styles.todoItem} onClick={() => navigate('/approval/pending')}>
<div className={styles.todoIcon}>
{todo.type === 'approval' && '📋'}
{todo.type === 'notice' && '🔔'}
{todo.type === 'task' && '✓'}
</div>
<div className={styles.todoContent}>
<div className={styles.todoTitle}>{todo.title}</div>
<div className={styles.todoTime}>{todo.time}</div>
</div>
<div className={styles.todoArrow}>{'>'}</div>
</div>
))}
</div>
) : (
<div className={styles.empty}>暂无待办事项</div>
)}
</div>
{/* 快捷入口 */}
<div className={styles.section}>
<h3 className={styles.sectionTitle}>快捷入口</h3>
<div className={styles.actionGrid}>
{quickActions.map((action) => (
{quickActions.slice(0, 3).map((action) => (
<div
key={action.id}
className={styles.actionCard}
onClick={() => handleQuickAction(action)}
>
<div className={styles.actionIcon} style={{ backgroundColor: `${action.color}15` }}>
<span style={{ fontSize: 28 }}>{action.icon}</span>
<span style={{ fontSize: 32 }}>{action.icon}</span>
</div>
<span className={styles.actionLabel}>{action.label}</span>
</div>
@@ -143,26 +92,27 @@ export default function Dashboard() {
</div>
</div>
{/* 最新动态 */}
{/* 所有应用 */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<h3 className={styles.sectionTitle}>最新动态</h3>
<span className={styles.more}>更多 {'>'}</span>
</div>
<div className={styles.activityList}>
<div className={styles.activityItem}>
<div className={styles.activityDot}></div>
<div className={styles.activityContent}>
<div>张三 提交了出差申请</div>
<div className={styles.activityTime}>10分钟前</div>
<h3 className={styles.sectionTitle}>所有应用</h3>
<div className={styles.actionGrid}>
<div className={styles.actionCard} onClick={() => navigate('/business-trip')}>
<div className={styles.actionIcon} style={{ backgroundColor: '#1677ff15' }}>
<span style={{ fontSize: 32 }}>✍️</span>
</div>
<span className={styles.actionLabel}>审批</span>
</div>
<div className={styles.activityItem}>
<div className={styles.activityDot}></div>
<div className={styles.activityContent}>
<div>李四 审批通过了采购申请</div>
<div className={styles.activityTime}>1小时前</div>
<div className={styles.actionCard} onClick={() => handleQuickAction(quickActions[8])}>
<div className={styles.actionIcon} style={{ backgroundColor: `${quickActions[8].color}15` }}>
<span style={{ fontSize: 32 }}>{quickActions[8].icon}</span>
</div>
<span className={styles.actionLabel}>{quickActions[8].label}</span>
</div>
<div className={styles.actionCard} onClick={() => handleQuickAction(quickActions[11])}>
<div className={styles.actionIcon} style={{ backgroundColor: `${quickActions[11].color}15` }}>
<span style={{ fontSize: 32 }}>{quickActions[11].icon}</span>
</div>
<span className={styles.actionLabel}>{quickActions[11].label}</span>
</div>
</div>
</div>
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Input, Toast } from 'antd-mobile'
import styles from './login-simple.module.css'
export default function SimpleLogin() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = async () => {
if (!username || !password) {
Toast.show('请输入用户名和密码')
return
}
setLoading(true)
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
const result = await response.json()
if (result.code === 0) {
// 登录成功,保存 token
localStorage.setItem('token', result.data.token)
localStorage.setItem('user', JSON.stringify(result.data.user))
Toast.show('登录成功')
// 跳转到首页
navigate('/dashboard')
} else {
Toast.show(result.message || '登录失败')
}
} catch (error: any) {
Toast.show(error.message || '登录失败,请重试')
} finally {
setLoading(false)
}
}
return (
<div className={styles.loginPage}>
<div className={styles.loginCard}>
<h1 className={styles.title}>企业工具台</h1>
<p className={styles.subtitle}>出差申请系统</p>
<div className={styles.form}>
<div className={styles.field}>
<label>用户名</label>
<Input
placeholder="请输入用户名"
value={username}
onChange={setUsername}
clearable
/>
</div>
<div className={styles.field}>
<label>密码</label>
<Input
type="password"
placeholder="请输入密码"
value={password}
onChange={setPassword}
clearable
/>
</div>
<Button
block
color="primary"
size="large"
onClick={handleLogin}
loading={loading}
>
登录
</Button>
<p className={styles.tip}>
💡 开发模式:输入任意用户名密码即可登录
</p>
</div>
</div>
</div>
)
}
@@ -0,0 +1,57 @@
.loginPage {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.loginCard {
background: #fff;
border-radius: 16px;
padding: 40px 30px;
width: 100%;
max-width: 400px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.title {
text-align: center;
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.subtitle {
text-align: center;
font-size: 14px;
color: #999;
margin: 0 0 30px 0;
}
.form {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field label {
font-size: 14px;
font-weight: 500;
color: #333;
}
.tip {
text-align: center;
font-size: 12px;
color: #999;
margin: 20px 0 0 0;
}
+2 -4
View File
@@ -1,14 +1,13 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import MobileLayout from '@/components/layout/MobileLayout'
import Dashboard from '@/pages/dashboard'
import Apps from '@/pages/apps'
import BusinessTripList from '@/pages/business-trip/list'
import BusinessTripApply from '@/pages/business-trip/apply'
import BusinessTripDetail from '@/pages/business-trip/detail'
import SelectApprover from '@/pages/business-trip/select-approver'
import SelectCc from '@/pages/business-trip/select-cc'
import Profile from '@/pages/profile'
import Login from '@/pages/login'
import SimpleLogin from '@/pages/login-simple'
const router = createBrowserRouter([
{
@@ -17,16 +16,15 @@ const router = createBrowserRouter([
children: [
{ index: true, element: <Navigate to='/dashboard' replace /> },
{ path: 'dashboard', element: <Dashboard /> },
{ path: 'apps', element: <Apps /> },
{ path: 'business-trip', element: <BusinessTripList /> },
{ path: 'business-trip/apply', element: <BusinessTripApply /> },
{ path: 'business-trip/apply/select-approver', element: <SelectApprover /> },
{ path: 'business-trip/apply/select-cc', element: <SelectCc /> },
{ path: 'business-trip/:id', element: <BusinessTripDetail /> },
{ path: 'profile', element: <Profile /> },
],
},
{ path: '/login', element: <Login /> },
{ path: '/login-simple', element: <SimpleLogin /> },
{ path: '*', element: <Navigate to='/dashboard' replace /> },
])
+5
View File
@@ -81,6 +81,11 @@ export const businessTripApi = {
}),
cancel: (id: number) => api.post(`/business-trip/cancel/${id}`),
deleteDraft: (id: number) => api.delete(`/business-trip/${id}`),
approve: (id: number, action: 'approve' | 'reject', comment?: string) =>
api.post(`/business-trip/approve/${id}`, { action, comment }),
}
// ========== 审批 API ==========
+33
View File
@@ -0,0 +1,33 @@
/**
* 设置登录 Token
*
* 使用方法:
* 1. 打开浏览器开发者工具(F12)
* 2. 切换到 Console 标签
* 3. 复制整个文件内容,粘贴到控制台
* 4. 按回车执行
*/
(function() {
const TOKEN = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJjb21wYW55X2lkIjoyLCJ1c2VybmFtZSI6Ilx1Njc0ZVx1NGY3M1x1NTM1YSIsImlhdCI6MTc3MjAwOTA0MCwiZXhwIjoxNzcyNjEzODQwfQ.mFsGM4bxRZdAMTQ3QgHO-DoFCWZUhUNY2N62VkN6FZ4';
localStorage.setItem('token', TOKEN);
console.log('%c✅ Token 设置成功!', 'color: green; font-size: 16px; font-weight: bold;');
console.log('%c用户信息:', 'color: blue; font-weight: bold;');
console.log(' 姓名: 李佳博');
console.log(' 邮箱: mzl@hnxinyouhe.com');
console.log(' 用户ID: 1');
console.log(' 公司ID: 2');
console.log('');
console.log('%cToken 有效期到: 2025-11-02', 'color: gray;');
console.log('%c请刷新页面后继续使用。', 'color: orange; font-weight: bold;');
// 验证 token 是否设置成功
const saved = localStorage.getItem('token');
if (saved === TOKEN) {
console.log('%c✓ 验证通过,token 已正确保存', 'color: green;');
} else {
console.error('✗ Token 保存失败!');
}
})();