fix: 修复页面间状态丢失问题并优化选择页面

- 修复选择审批人后再选择抄送人导致状态丢失的问题
- 使用独立的 sessionStorage key (selectedApprovers, selectedCc)
- 组件每次挂载时从 sessionStorage 恢复状态
- 只在提交/保存成功后清除 sessionStorage
- 优化人员选择页面布局和样式
- 更新 CLAUDE.md 文档记录问题和解决方案

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-25 15:22:12 +08:00
parent 2d39357cc3
commit 09d4cb8054
51 changed files with 2944 additions and 647 deletions
+121
View File
@@ -180,6 +180,127 @@ docker logs x-server-backend -f
## 已知问题和解决方案
### 页面间状态丢失问题 ⚠️ 重要!
#### 问题1:navigate(-1) 不传递 state
**现象:** 使用 `navigate(-1, { state })` 返回上一页时,`location.state` 为 `null`
**原因:** React Router 在使用 `navigate(-1)` 时,`state` 参数不可靠
**解决方案:** 使用 sessionStorage 传递数据
#### 问题2:多次选择导致状态丢失
**现象:**
- 选择审批人后,再选择抄送人,审批人状态丢失
- 先选择抄送人,再选择审批人,抄送人状态丢失
**原因:**
- 每次导航时,目标页面组件会重新挂载
- 重新挂载时,所有 useState 都会重置为初始值
- sessionStorage 在第一次读取后被清除,第二次挂载时无数据可恢复
**解决方案:**
1. **使用独立的 sessionStorage key**(不要共用一个 key)
2. **不在 useEffect 中立即清除 sessionStorage**
3. **每次挂载时从 sessionStorage 恢复状态**
4. **只在业务操作完成后才清除 sessionStorage**(提交、保存等)
**正确实现:**
```tsx
// 选择页面 - 使用独立的 key
const handleConfirm = () => {
if (type === 'approver') {
sessionStorage.setItem('selectedApprovers', JSON.stringify(selectedUsers))
} else if (type === 'cc') {
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
}
navigate(-1)
}
// 申请页面 - 每次挂载时恢复状态
useEffect(() => {
const approversData = sessionStorage.getItem('selectedApprovers')
const ccData = sessionStorage.getItem('selectedCc')
if (approversData) {
setApprovers(JSON.parse(approversData))
}
if (ccData) {
setCcUsers(JSON.parse(ccData))
}
}, [])
// 提交/保存成功后清除
const handleSubmit = async () => {
// ... 提交逻辑 ...
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
navigate('/business-trip')
}
```
**关键点:**
- ✅ 不同类型数据使用独立的 sessionStorage key
- ✅ 不在 useEffect 中清除 sessionStorage
- ✅ 每次挂载都恢复状态,确保组件重新挂载时状态不丢失
- ✅ 只在业务操作完成后清除 sessionStorage
**相关文件:**
- `frontend/src/pages/business-trip/select-approver.tsx`
- `frontend/src/pages/business-trip/select-cc.tsx`
- `frontend/src/pages/business-trip/apply.tsx`
### Ant Design Mobile DatePicker 点击无反应
**问题:** DatePicker 组件使用 children render prop 方式时,点击无法弹出选择框
**原因:** antd-mobile 5.x 的 DatePicker children render prop 点击事件绑定存在问题
**解决方案:** 使用 `visible` 属性手动控制弹框显示
```tsx
// ❌ 错误用法 - 点击无反应
<DatePicker value={value} onConfirm={setValue}>
{(value) => <button>{value ? '已选择' : '请选择'}</button>}
</DatePicker>
// ✅ 正确用法 - 使用 visible 属性
const [visible, setVisible] = useState(false)
<div onClick={() => setVisible(true)}>
{value ? '已选择' : '请选择'}
</div>
<DatePicker
visible={visible}
onConfirm={(val) => {
setValue(val)
setVisible(false)
}}
onClose={() => setVisible(false)}
onCancel={() => setVisible(false)}
/>
```
**相关文件:**
- `frontend/src/main.tsx` - 需要导入 `import 'antd-mobile/es/global'`
- `frontend/src/pages/business-trip/apply.tsx` - 正确使用示例
### Ant Design Mobile 全局样式导入
**必须导入全局样式:**
```tsx
import 'antd-mobile/es/global'
```
否则弹框、遮罩等组件将无法正常显示。
### Selector 组件对号图标隐藏
**问题:** Selector 组件选中时显示对号图标和背景色,想要只保留背景色
**解决方案:** 使用 `showCheckMark` 属性
```tsx
<Selector showCheckMark={false} options={options} />
```
### PSR-4 自动加载
- 目录名必须是 PascalCase(Controller、Model、Service、Util)
- 不能用小写(controller、model、service、util)
+15
View File
@@ -0,0 +1,15 @@
<?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();
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Controller;
use App\Service\ApprovalService;
use Webman\Http\Request;
use Webman\Http\Response;
class ApprovalController extends BaseController
{
private ApprovalService $service;
public function __construct()
{
$this->service = new ApprovalService();
}
/**
* 获取审批列表
*/
public function list(Request $request): Response
{
$userId = $this->getUserId($request);
$type = $request->get('type', 'my');
$page = (int) $request->get('page', 1);
$pageSize = (int) $request->get('page_size', 20);
$result = $this->service->getList($userId, $type, $page, $pageSize);
return $this->success($result);
}
/**
* 获取审批详情
*/
public function detail(Request $request): Response
{
$id = (int) $request->route('id');
$approval = \App\Model\ApprovalOrder::with(['flow', 'applicant', 'records'])
->findOrFail($id);
return $this->success($approval);
}
/**
* 审批通过
*/
public function approve(Request $request): Response
{
$userId = $this->getUserId($request);
$id = (int) $request->route('id');
$data = $request->post();
$data = $data ?? [];
$comment = $data['comment'] ?? null;
try {
$approval = $this->service->approve($id, $userId, $comment);
return $this->success($approval, '已通过');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 审批拒绝
*/
public function reject(Request $request): Response
{
$userId = $this->getUserId($request);
$id = (int) $request->route('id');
$data = $request->post();
$data = $data ?? [];
$comment = $data['comment'] ?? null;
try {
$approval = $this->service->reject($id, $userId, $comment);
return $this->success($approval, '已拒绝');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 撤销申请
*/
public function cancel(Request $request): Response
{
$userId = $this->getUserId($request);
$id = (int) $request->route('id');
try {
$approval = $this->service->cancel($id, $userId);
return $this->success($approval, '已撤销');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Controller;
use App\Service\FeishuService;
use App\Util\JwtUtil;
use App\Model\User;
use App\Model\Company;
use Webman\Http\Request;
use Webman\Http\Response;
class AuthController extends BaseController
{
/**
* 用户登录
*/
public function login(Request $request): Response
{
$username = $request->post('username');
$password = $request->post('password');
// TODO: 实现真实的登录逻辑
if (!$username || !$password) {
return $this->error('用户名或密码不能为空');
}
// 模拟用户数据(用于测试)
$user = User::first();
if (!$user) {
return $this->error('用户不存在');
}
$token = JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'username' => $user->name,
]);
return $this->success([
'token' => $token,
'user' => $user,
]);
}
/**
* 飞书免登
*/
public function feishuLogin(Request $request): Response
{
$authCode = $request->post('auth_code');
$tenantKey = $request->post('tenant_key');
if (!$authCode) {
return $this->error('缺少授权码');
}
try {
$feishuService = new FeishuService();
// 根据 tenant_key 获取公司
$company = $feishuService->getCompanyByTenantKey($tenantKey);
if (!$company) {
return $this->error('公司不存在');
}
// 设置公司并获取用户信息
$feishuService->setCompany($company->id);
$feishuData = $feishuService->getUserInfo($authCode);
// 创建或更新用户
$user = $feishuService->createOrUpdateUser($feishuData, $company->id);
// 生成 JWT Token
$token = JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'feishu_user_id' => $user->feishu_user_id,
'union_id' => $user->union_id,
]);
return $this->success([
'token' => $token,
'user' => $user->load('company'),
]);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 获取当前用户信息
*/
public function me(Request $request): Response
{
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('未登录', 401);
}
$user = User::with('company')->find($userId);
if (!$user) {
return $this->error('用户不存在');
}
return $this->success($user);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Controller;
use Webman\Http\Request;
use Webman\Http\Response;
abstract class BaseController
{
/**
* 成功响应
*/
protected function success($data = null, string $message = 'success'): Response
{
return json([
'code' => 0,
'message' => $message,
'data' => $data,
]);
}
/**
* 错误响应
*/
protected function error(string $message = 'error', int $code = 1, $data = null): Response
{
return json([
'code' => $code,
'message' => $message,
'data' => $data,
]);
}
/**
* 获取当前用户ID
*/
protected function getUserId(Request $request): ?int
{
// 先检查 request 中是否已注入
if (isset($request->userId)) {
return $request->userId;
}
// 从 Authorization header 获取 token
$token = $request->header('Authorization');
if ($token) {
$token = str_replace('Bearer ', '', $token);
}
if (!$token) {
return null;
}
// 解析 token
$payload = \App\Util\JwtUtil::decode($token);
if (!$payload) {
return null;
}
return (int) ($payload['user_id'] ?? null);
}
/**
* 验证认证并返回用户ID,失败则抛出异常
*/
protected function requireAuth(Request $request): int
{
$userId = $this->getUserId($request);
if (!$userId) {
throw new \Exception('未登录或登录已过期', 401);
}
return $userId;
}
}
@@ -0,0 +1,165 @@
<?php
namespace App\Controller;
use App\Service\BusinessTripService;
use Webman\Http\Request;
use Webman\Http\Response;
class BusinessTripController extends BaseController
{
private BusinessTripService $service;
public function __construct()
{
$this->service = new BusinessTripService();
}
/**
* 获取路由参数
*/
private function getRouteParam(Request $request, string $name, $default = null)
{
$route = $request->route;
if ($route && method_exists($route, 'param')) {
return $route->param($name) ?? $default;
}
return $default;
}
/**
* 获取出差申请列表
*/
public function list(Request $request): Response
{
$userId = $this->getUserId($request);
$type = $request->get('type', 'my');
$page = (int) $request->get('page', 1);
$pageSize = (int) $request->get('page_size', 20);
$result = $this->service->getList($userId, $type, $page, $pageSize);
return $this->success($result);
}
/**
* 获取出差申请详情
*/
public function detail(Request $request): Response
{
$id = (int) $this->getRouteParam($request, 'id');
if (!$id) {
return $this->error('参数错误');
}
$detail = $this->service->getDetail($id);
return $this->success($detail);
}
/**
* 创建出差申请(草稿)
*/
public function create(Request $request): Response
{
$userId = $this->getUserId($request);
$data = $request->post();
$data = $data ?? [];
// 验证必填字段
if (empty($data['reason'])) {
return $this->error('请输入出差原因');
}
if (empty($data['itineraries']) || !is_array($data['itineraries'])) {
return $this->error('请至少添加一条行程');
}
// 验证行程数据
foreach ($data['itineraries'] as $index => $itinerary) {
if (empty($itinerary['start_time'])) {
return $this->error("行程" . ($index + 1) . ":请选择开始时间");
}
if (empty($itinerary['destination'])) {
return $this->error("行程" . ($index + 1) . ":请输入目的地");
}
}
try {
$businessTrip = $this->service->createDraft($userId, $data);
return $this->success($businessTrip, '创建成功');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 更新出差申请
*/
public function update(Request $request): Response
{
$userId = $this->getUserId($request);
$id = (int) $this->getRouteParam($request, 'id');
if (!$id) {
return $this->error('参数错误');
}
$data = $request->post();
$data = $data ?? [];
try {
$businessTrip = $this->service->update($userId, $id, $data);
return $this->success($businessTrip, '更新成功');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 提交出差申请
*/
public function submit(Request $request): Response
{
$userId = $this->getUserId($request);
$id = (int) $this->getRouteParam($request, 'id');
if (!$id) {
return $this->error('参数错误');
}
$data = $request->post();
$data = $data ?? [];
$approvers = $data['approvers'] ?? [];
$ccUsers = $data['cc_users'] ?? [];
if (empty($approvers)) {
return $this->error('请选择审批人');
}
try {
$businessTrip = $this->service->submit($userId, $id, $approvers, $ccUsers);
return $this->success($businessTrip, '提交成功');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 取消出差申请
*/
public function cancel(Request $request): Response
{
$userId = $this->getUserId($request);
$id = (int) $this->getRouteParam($request, 'id');
if (!$id) {
return $this->error('参数错误');
}
try {
$businessTrip = $this->service->cancel($userId, $id);
return $this->success($businessTrip, '取消成功');
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
}
@@ -1,7 +1,8 @@
<?php
namespace app\controller;
namespace App\Controller;
use App\Model\User;
use Webman\Http\Request;
use Webman\Http\Response;
@@ -13,14 +14,13 @@ class UserController extends BaseController
public function info(Request $request): Response
{
$userId = $this->getUserId($request);
$user = User::find($userId);
// TODO: 从数据库获取用户信息
return $this->success([
'id' => $userId,
'name' => '测试用户',
'email' => 'test@example.com',
'company_id' => 1,
]);
if (!$user) {
return $this->error('用户不存在');
}
return $this->success($user);
}
/**
@@ -59,4 +59,34 @@ class UserController extends BaseController
return $this->success();
}
/**
* 获取用户列表(用于选择审批人等)
*/
public function list(Request $request): Response
{
$userId = $this->getUserId($request);
$keyword = $request->get('keyword', '');
$page = (int) $request->get('page', 1);
$pageSize = (int) $request->get('page_size', 50);
$query = User::active();
if ($keyword) {
$query->search($keyword);
}
$total = $query->count();
$list = $query->orderBy('id')
->offset(($page - 1) * $pageSize)
->limit($pageSize)
->get();
return $this->success([
'list' => $list,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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\model;
namespace App\Model;
use support\Model;
use Illuminate\Database\Eloquent\Model;
class ApprovalFlow extends Model
{
@@ -1,8 +1,8 @@
<?php
namespace app\model;
namespace App\Model;
use support\Model;
use Illuminate\Database\Eloquent\Model;
class ApprovalNode extends Model
{
@@ -1,8 +1,8 @@
<?php
namespace app\model;
namespace App\Model;
use support\Model;
use Illuminate\Database\Eloquent\Model;
class ApprovalOrder extends Model
{
@@ -1,8 +1,8 @@
<?php
namespace app\model;
namespace App\Model;
use support\Model;
use Illuminate\Database\Eloquent\Model;
class ApprovalRecord extends Model
{
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
class BusinessTrip extends Model
{
protected $table = 'business_trip';
protected $fillable = [
'user_id',
'reason',
'start_date',
'end_date',
'destination',
'estimated_cost',
'status',
'approval_order_id',
];
protected $casts = [
'user_id' => 'integer',
'estimated_cost' => 'decimal:2',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 状态常量
*/
const STATUS_DRAFT = 'draft'; // 草稿
const STATUS_PENDING = 'pending'; // 审批中
const STATUS_APPROVED = 'approved'; // 已通过
const STATUS_REJECTED = 'rejected'; // 已拒绝
const STATUS_CANCELLED = 'cancelled'; // 已撤销
/**
* 关联用户
*/
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* 关联行程明细
*/
public function itineraries()
{
return $this->hasMany(BusinessTripItinerary::class, 'business_trip_id');
}
/**
* 关联审批单
*/
public function approvalOrder()
{
return $this->belongsTo(ApprovalOrder::class, 'approval_order_id');
}
/**
* 计算总天数
*/
public function getDurationDays(): int
{
if (!$this->start_date || !$this->end_date) {
return 0;
}
$start = \DateTime::createFromFormat('Y-m-d', $this->start_date);
$end = \DateTime::createFromFormat('Y-m-d', $this->end_date);
if (!$start || !$end) {
return 0;
}
return $end->diff($start)->days + 1;
}
/**
* 判断是否可以编辑
*/
public function canEdit(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 判断是否可以提交
*/
public function canSubmit(): bool
{
return $this->status === self::STATUS_DRAFT;
}
/**
* 判断是否可以取消
*/
public function canCancel(): bool
{
return $this->status === self::STATUS_PENDING;
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
class BusinessTripItinerary extends Model
{
protected $table = 'business_trip_itinerary';
protected $fillable = [
'business_trip_id',
'start_time',
'end_time',
'departure',
'destination',
'transport',
'trip_type',
'remark',
];
protected $casts = [
'business_trip_id' => 'integer',
'start_time' => 'datetime',
'end_time' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 行程类型常量
*/
const TRIP_TYPE_ONEWAY = 'oneway'; // 单程
const TRIP_TYPE_ROUNDTRIP = 'roundtrip'; // 往返
/**
* 关联出差申请
*/
public function businessTrip()
{
return $this->belongsTo(BusinessTrip::class, 'business_trip_id');
}
/**
* 计算天数
*/
public function getDurationDays(): int
{
if (!$this->start_time || !$this->end_time) {
return 0;
}
$start = \DateTime::createFromFormat('Y-m-d H:i:s', $this->start_time);
$end = \DateTime::createFromFormat('Y-m-d H:i:s', $this->end_time);
if (!$start || !$end) {
return 0;
}
return $end->diff($start)->days + 1;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
class Company extends Model
{
protected $table = 'companies';
protected $fillable = [
'name',
'abbr',
'tenant_key',
'sort',
'status',
'feishu_enabled',
];
protected $casts = [
'sort' => 'integer',
'status' => 'integer',
'feishu_enabled' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 获取启用的公司
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据 tenant_key 查找
*/
public function scopeByTenantKey($query, ?string $tenantKey)
{
if ($tenantKey === null) {
return $query;
}
return $query->where('tenant_key', $tenantKey);
}
/**
* 关联飞书应用配置
*/
public function feishuApps()
{
return $this->hasMany(CompanyFeishuApp::class, 'company_id');
}
/**
* 关联用户
*/
public function users()
{
return $this->hasMany(User::class, 'company_id');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Model;
use Illuminate\Database\Eloquent\Model;
class CompanyFeishuApp extends Model
{
protected $table = 'company_feishu_apps';
protected $fillable = [
'company_id',
'system_id',
'name',
'tenant_key',
'app_id',
'app_secret',
'base_uri',
'callback_url',
'mobile_callback_url',
'status',
];
protected $casts = [
'company_id' => 'integer',
'system_id' => 'integer',
'status' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 获取启用的应用
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 关联公司
*/
public function company()
{
return $this->belongsTo(Company::class, 'company_id');
}
}
@@ -1,8 +1,8 @@
<?php
namespace app\model;
namespace App\Model;
use support\Model;
use Illuminate\Database\Eloquent\Model;
class User extends 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
+303
View File
@@ -0,0 +1,303 @@
<?php
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 Illuminate\Database\Connection;
class BusinessTripService
{
private Connection $db;
public function __construct()
{
Db::init();
$this->db = Db::connection();
}
/**
* 创建出差申请(草稿)
*/
public function createDraft(int $userId, array $data): BusinessTrip
{
return $this->db->transaction(function () use ($userId, $data) {
try {
// 计算最早和最晚时间
$itineraries = $data['itineraries'] ?? [];
if (empty($itineraries)) {
throw new \Exception('请至少添加一条行程');
}
$startDate = null;
$endDate = null;
$destinations = [];
foreach ($itineraries as $itinerary) {
if ($itinerary['start_time']) {
if (!$startDate || $itinerary['start_time'] < $startDate) {
$startDate = $itinerary['start_time'];
}
}
if ($itinerary['end_time']) {
if (!$endDate || $itinerary['end_time'] > $endDate) {
$endDate = $itinerary['end_time'];
}
}
if (!empty($itinerary['destination'])) {
$destinations[] = $itinerary['destination'];
}
}
// 创建出差申请
$businessTrip = BusinessTrip::create([
'user_id' => $userId,
'reason' => $data['reason'] ?? '',
'start_date' => $startDate ? date('Y-m-d', strtotime($startDate)) : null,
'end_date' => $endDate ? date('Y-m-d', strtotime($endDate)) : null,
'destination' => implode(', ', array_unique($destinations)),
'estimated_cost' => $data['estimated_cost'] ?? 0,
'status' => BusinessTrip::STATUS_DRAFT,
]);
// 创建行程明细
foreach ($itineraries as $itinerary) {
BusinessTripItinerary::create([
'business_trip_id' => $businessTrip->id,
'start_time' => $itinerary['start_time'],
'end_time' => $itinerary['end_time'] ?? null,
'departure' => $itinerary['departure'] ?? '',
'destination' => $itinerary['destination'] ?? '',
'transport' => $itinerary['transport'] ?? '',
'trip_type' => $itinerary['trip_type'] ?? 'oneway',
'remark' => $itinerary['remark'] ?? '',
]);
}
return $businessTrip->load('itineraries');
} catch (\Exception $e) {
error_log('BusinessTripService::createDraft error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
throw $e;
}
});
}
/**
* 提交出差申请
*/
public function submit(int $userId, int $businessTripId, array $approvers, array $ccUsers = []): BusinessTrip
{
return $this->db->transaction(function () use ($userId, $businessTripId, $approvers, $ccUsers) {
$businessTrip = BusinessTrip::with('itineraries')->findOrFail($businessTripId);
if ($businessTrip->user_id !== $userId) {
throw new \Exception('无权操作此申请');
}
if (!$businessTrip->canSubmit()) {
throw new \Exception('当前状态无法提交');
}
// 获取审批流程(简化版:直接使用第一个审批人作为单人审批)
$flow = ApprovalFlow::where('business_type', 'business_trip')
->where('status', 1)
->first();
if (!$flow) {
// 创建默认流程
$flow = ApprovalFlow::create([
'business_type' => 'business_trip',
'name' => '出差审批',
'description' => '出差申请审批流程',
'status' => 1,
]);
}
// 创建审批单
$approvalOrder = ApprovalOrder::create([
'business_type' => 'business_trip',
'business_id' => $businessTrip->id,
'title' => '出差申请 - ' . $businessTrip->user->name,
'applicant_id' => $userId,
'status' => ApprovalOrder::STATUS_PENDING,
'current_nodes' => [1], // 简化:使用节点1
'flow_id' => $flow->id,
'form_data' => [
'reason' => $businessTrip->reason,
'start_date' => $businessTrip->start_date,
'end_date' => $businessTrip->end_date,
'destination' => $businessTrip->destination,
'approvers' => $approvers,
'cc_users' => $ccUsers,
],
]);
// 更新出差申请状态
$businessTrip->status = BusinessTrip::STATUS_PENDING;
$businessTrip->approval_order_id = $approvalOrder->id;
$businessTrip->save();
// 记录提交操作
ApprovalRecord::create([
'order_id' => $approvalOrder->id,
'node_id' => 1,
'node_type' => 'single_approve',
'approver_id' => $userId,
'action' => 'submit',
'comment' => null,
]);
return $businessTrip->fresh(['itineraries', 'approvalOrder']);
});
}
/**
* 获取出差申请列表
*/
public function getList(int $userId, string $type = 'my', int $page = 1, int $pageSize = 20): array
{
$query = BusinessTrip::with(['user', 'itineraries']);
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')
->toArray();
$query->whereIn('id', $pendingOrderIds);
break;
}
$total = $query->count();
$list = $query->orderBy('created_at', 'desc')
->offset(($page - 1) * $pageSize)
->limit($pageSize)
->get();
return [
'list' => $list,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
];
}
/**
* 获取出差申请详情
*/
public function getDetail(int $id): BusinessTrip
{
return BusinessTrip::with(['user', 'itineraries'])
->findOrFail($id);
}
/**
* 更新出差申请
*/
public function update(int $userId, int $id, array $data): BusinessTrip
{
$businessTrip = BusinessTrip::findOrFail($id);
if ($businessTrip->user_id !== $userId) {
throw new \Exception('无权操作此申请');
}
if (!$businessTrip->canEdit()) {
throw new \Exception('当前状态无法编辑');
}
return $this->db->transaction(function () use ($businessTrip, $data) {
// 更新基本信息
if (isset($data['reason'])) {
$businessTrip->reason = $data['reason'];
}
// 更新行程
if (isset($data['itineraries'])) {
// 删除旧行程
BusinessTripItinerary::where('business_trip_id', $businessTrip->id)->delete();
// 创建新行程
$startDate = null;
$endDate = null;
$destinations = [];
foreach ($data['itineraries'] as $itinerary) {
BusinessTripItinerary::create([
'business_trip_id' => $businessTrip->id,
'start_time' => $itinerary['start_time'],
'end_time' => $itinerary['end_time'] ?? null,
'departure' => $itinerary['departure'] ?? '',
'destination' => $itinerary['destination'] ?? '',
'transport' => $itinerary['transport'] ?? '',
'trip_type' => $itinerary['trip_type'] ?? 'oneway',
'remark' => $itinerary['remark'] ?? '',
]);
if ($itinerary['start_time']) {
if (!$startDate || $itinerary['start_time'] < $startDate) {
$startDate = $itinerary['start_time'];
}
}
if ($itinerary['end_time']) {
if (!$endDate || $itinerary['end_time'] > $endDate) {
$endDate = $itinerary['end_time'];
}
}
if (!empty($itinerary['destination'])) {
$destinations[] = $itinerary['destination'];
}
}
// 更新日期和目的地
$businessTrip->start_date = $startDate ? date('Y-m-d', strtotime($startDate)) : null;
$businessTrip->end_date = $endDate ? date('Y-m-d', strtotime($endDate)) : null;
$businessTrip->destination = implode(', ', array_unique($destinations));
}
$businessTrip->save();
return $businessTrip->fresh('itineraries');
});
}
/**
* 取消出差申请
*/
public function cancel(int $userId, int $id): BusinessTrip
{
$businessTrip = BusinessTrip::findOrFail($id);
if ($businessTrip->user_id !== $userId) {
throw new \Exception('无权操作此申请');
}
if (!$businessTrip->canCancel()) {
throw new \Exception('当前状态无法取消');
}
$businessTrip->status = BusinessTrip::STATUS_CANCELLED;
$businessTrip->save();
// 同时取消审批单
if ($businessTrip->approval_order_id) {
$approvalOrder = ApprovalOrder::find($businessTrip->approval_order_id);
if ($approvalOrder) {
$approvalOrder->status = ApprovalOrder::STATUS_CANCELLED;
$approvalOrder->current_nodes = [];
$approvalOrder->save();
}
}
return $businessTrip->fresh();
}
}
@@ -1,11 +1,11 @@
<?php
namespace app\service;
namespace App\Service;
use app\model\Company;
use app\model\User;
use app\util\Db;
use support\Cache;
use App\Model\Company;
use App\Model\CompanyFeishuApp;
use App\Model\User;
use App\Util\Db;
class FeishuService
{
@@ -13,6 +13,9 @@ class FeishuService
private ?int $companyId = null;
private ?int $appId = null;
// 简单的内存缓存(生产环境应使用 Redis)
private static array $tokenCache = [];
/**
* 设置当前操作的公司
*/
@@ -46,16 +49,13 @@ class FeishuService
*/
private function getFeishuApp(int $appId): array
{
$sql = "SELECT * FROM company_feishu_apps WHERE id = ? AND status = 1";
$stmt = Db::connection()->getPdo()->prepare($sql);
$stmt->execute([$appId]);
$app = $stmt->fetch(\PDO::FETCH_ASSOC);
$app = CompanyFeishuApp::find($appId);
if (!$app) {
if (!$app || $app->status !== 1) {
throw new \Exception('飞书应用配置不存在');
}
return $app;
return $app->toArray();
}
/**
@@ -63,16 +63,17 @@ class FeishuService
*/
private function getDefaultFeishuApp(int $companyId): array
{
$sql = "SELECT * FROM company_feishu_apps WHERE company_id = ? AND system_id = 1 AND status = 1 ORDER BY sort ASC LIMIT 1";
$stmt = Db::connection()->getPdo()->prepare($sql);
$stmt->execute([$companyId]);
$app = $stmt->fetch(\PDO::FETCH_ASSOC);
$app = CompanyFeishuApp::where('company_id', $companyId)
->where('system_id', 1)
->where('status', 1)
->orderBy('sort')
->first();
if (!$app) {
throw new \Exception('未配置飞书应用');
}
return $app;
return $app->toArray();
}
/**
@@ -101,14 +102,12 @@ class FeishuService
}
$app = $this->getAppConfig();
// 从缓存获取
$cacheKey = "feishu:access_token:{$app['id']}";
$token = Cache::get($cacheKey);
if ($token) {
$this->accessToken = $token;
return $token;
// 从内存缓存获取
if (isset(self::$tokenCache[$cacheKey]) && self::$tokenCache[$cacheKey]['expire'] > time()) {
$this->accessToken = self::$tokenCache[$cacheKey]['token'];
return $this->accessToken;
}
// 请求新的 token
@@ -125,7 +124,10 @@ class FeishuService
$expire = $response['expire'] ?? 7200;
// 缓存 token(提前5分钟过期)
Cache::set($cacheKey, $token, $expire - 300);
self::$tokenCache[$cacheKey] = [
'token' => $token,
'expire' => time() + $expire - 300,
];
$this->accessToken = $token;
return $token;
@@ -290,7 +292,7 @@ class FeishuService
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// curl_close($ch); // 不再需要,PHP 8.5+ 自动关闭
if ($httpCode !== 200) {
throw new \Exception("HTTP 请求失败: {$httpCode}");
@@ -1,6 +1,6 @@
<?php
namespace app\util;
namespace App\Util;
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Events\Dispatcher;
@@ -24,11 +24,11 @@ class Db
// Add connection
self::$capsule->addConnection([
'driver' => 'mysql',
'host' => getenv('DB_HOST') ?: '127.0.0.1',
'host' => getenv('DB_HOST') ?: '1Panel-mysql-409s',
'port' => getenv('DB_PORT') ?: '3306',
'database' => getenv('DB_DATABASE') ?: 'xserver',
'username' => getenv('DB_USERNAME') ?: 'root',
'password' => getenv('DB_PASSWORD') ?: '',
'password' => getenv('DB_PASSWORD') ?: 'mysql_xEsdPa',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
@@ -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;
/**
* 多语言翻译类
-59
View File
@@ -1,59 +0,0 @@
<?php
namespace app\controller;
use app\util\JwtUtil;
use Webman\Http\Request;
use Webman\Http\Response;
class AuthController extends BaseController
{
/**
* 用户登录
*/
public function login(Request $request): Response
{
$username = $request->post('username');
$password = $request->post('password');
// TODO: 实现真实的登录逻辑
if (!$username || !$password) {
return $this->error('用户名或密码不能空');
}
// 模拟用户数据
$user = [
'user_id' => 1,
'username' => $username,
'name' => '测试用户',
'company_id' => 1,
];
$token = JwtUtil::encode($user);
return $this->success([
'token' => $token,
'user' => $user,
]);
}
/**
* 飞书免登
*/
public function feishuLogin(Request $request): Response
{
$authCode = $request->post('auth_code');
$tenantKey = $request->post('tenant_key');
// TODO: 实现飞书免登逻辑
// 1. 根据 tenant_key 获取公司配置
// 2. 使用 auth_code 换取用户信息
// 3. 创建或更新用户
// 4. 生成 JWT Token
return $this->success([
'token' => JwtUtil::encode(['user_id' => 1, 'company_id' => 1]),
'user' => ['id' => 1, 'name' => '飞书用户', 'company_id' => 1],
]);
}
}
-41
View File
@@ -1,41 +0,0 @@
<?php
namespace app\controller;
use Webman\Http\Request;
use Webman\Http\Response;
abstract class BaseController
{
/**
* 成功响应
*/
protected function success($data = null, string $message = 'success'): Response
{
return json([
'code' => 0,
'message' => $message,
'data' => $data,
]);
}
/**
* 错误响应
*/
protected function error(string $message = 'error', int $code = 1, $data = null): Response
{
return json([
'code' => $code,
'message' => $message,
'data' => $data,
]);
}
/**
* 获取当前用户ID
*/
protected function getUserId(Request $request): ?int
{
return $request->userId ?? null;
}
}
-96
View File
@@ -1,96 +0,0 @@
<?php
namespace app\model;
use support\Model;
class Company extends Model
{
protected $table = 'companies';
protected $fillable = [
'name',
'abbr',
'tenant_key',
'sort',
'status',
'feishu_enabled',
];
protected $casts = [
'sort' => 'integer',
'status' => 'integer',
'feishu_enabled' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* 关联用户
*/
public function users()
{
return $this->hasMany(User::class, 'company_id');
}
/**
* 获取启用的公司
*/
public function scopeActive($query)
{
return $query->where('status', 1);
}
/**
* 根据租户Key查找
*/
public function scopeByTenantKey($query, string $tenantKey)
{
return $query->where('tenant_key', $tenantKey);
}
/**
* 根据缩写查找
*/
public function scopeByAbbr($query, string $abbr)
{
return $query->where('abbr', $abbr);
}
/**
* 搜索公司
*/
public function scopeSearch($query, ?string $keyword)
{
if (!$keyword) {
return $query;
}
return $query->where(function ($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%")
->orWhere('abbr', 'like', "%{$keyword}%");
});
}
/**
* 获取默认飞书应用配置
*/
public function getDefaultFeishuApp(): ?array
{
$sql = "SELECT * FROM company_feishu_apps WHERE company_id = ? AND system_id = 1 AND status = 1 ORDER BY sort ASC LIMIT 1";
$stmt = \app\util\Db::connection()->getPdo()->prepare($sql);
$stmt->execute([$this->id]);
return $stmt->fetch(\PDO::FETCH_ASSOC);
}
/**
* 获取所有飞书应用配置
*/
public function getFeishuApps(): array
{
$sql = "SELECT * FROM company_feishu_apps WHERE company_id = ? AND status = 1 ORDER BY sort";
$stmt = \app\util\Db::connection()->getPdo()->prepare($sql);
$stmt->execute([$this->id]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
App\Bootstrap\Database::class,
];
+1 -1
View File
@@ -5,7 +5,7 @@ return [
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => getenv('DB_HOST') ?: 'mysql',
'host' => getenv('DB_HOST') ?: '1Panel-mysql-409s',
'port' => getenv('DB_PORT') ?: 3306,
'database' => getenv('DB_DATABASE') ?: 'xserver',
'username' => getenv('DB_USERNAME') ?: 'root',
+25 -23
View File
@@ -4,35 +4,37 @@ 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::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::post('/user/switch-company', [app\controller\UserController::class, 'switchCompany']);
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/submit', [app\controller\ApprovalController::class, 'submit']);
// 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('/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/apply', [app\controller\BusinessTripController::class, 'apply']);
// Route::put('/business-trip/{id}', [app\controller\BusinessTripController::class, 'update']);
// });
// 出差审批路由(需要认证)
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']);
});
});
// 默认路由
+1 -1
View File
@@ -10,7 +10,7 @@ services:
ports:
- "8787:8787"
environment:
- DB_HOST=mysql
- DB_HOST=1Panel-mysql-409s
- DB_PORT=3306
- DB_DATABASE=xserver
- DB_USERNAME=root
+1 -1
View File
@@ -10,7 +10,7 @@
"dependencies": {
"@tanstack/react-query": "^5.62.11",
"antd-mobile": "^5.38.1",
"axios": "^1.7.9",
"axios": "^1.13.5",
"dayjs": "^1.11.13",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@tanstack/react-query": "^5.62.11",
"antd-mobile": "^5.38.1",
"axios": "^1.7.9",
"axios": "^1.13.5",
"dayjs": "^1.11.13",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
+2
View File
@@ -13,6 +13,7 @@ html,
body {
width: 100%;
height: 100%;
overflow-x: hidden;
}
body {
@@ -26,6 +27,7 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-size-adjust: 100%; /* 防止 iOS 自动调整字体大小 */
overflow-y: auto;
}
#root {
+10 -1
View File
@@ -1,6 +1,13 @@
import { StrictMode, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { ConfigProvider } from 'antd-mobile'
import zhCN from 'antd-mobile/es/locales/zh-CN'
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 router from './router'
import { initRem } from './utils/rem'
import './i18n'
@@ -20,6 +27,8 @@ function App() {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</StrictMode>
)
@@ -12,22 +12,50 @@ interface ApprovalActionProps {
export default function ApprovalAction({ orderId, status }: ApprovalActionProps) {
const { approve, reject, cancel, isApproving, isRejecting, isCancelling } = useApprovalAction()
const [comment, setComment] = useState('')
const [showModal, setShowModal] = useState(false)
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve')
const canAction = status === 'pending'
const canCancel = status === 'pending' || status === 'draft'
const handleApprove = () => {
approve({ orderId, comment: comment || undefined })
setShowModal(false)
setComment('')
Modal.confirm({
content: (
<div className={styles.modalContent}>
<div className={styles.modalTitle}>确认通过此申请?</div>
<textarea
className={styles.textarea}
placeholder="请输入审批意见(可选)"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
/>
</div>
),
onConfirm: async () => {
await approve({ orderId, comment: comment || undefined })
setComment('')
},
})
}
const handleReject = () => {
reject({ orderId, comment: comment || '已拒绝' })
setShowModal(false)
setComment('')
Modal.confirm({
content: (
<div className={styles.modalContent}>
<div className={styles.modalTitle}>确认拒绝此申请?</div>
<textarea
className={styles.textarea}
placeholder="请输入拒绝原因"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
/>
</div>
),
onConfirm: async () => {
await reject({ orderId, comment: comment || '已拒绝' })
setComment('')
},
})
}
const handleCancel = () => {
@@ -36,76 +64,39 @@ export default function ApprovalAction({ orderId, status }: ApprovalActionProps)
}
}
const openModal = (action: 'approve' | 'reject') => {
setActionType(action)
setShowModal(true)
}
if (!canAction && !canCancel) {
return null
}
return (
<>
<div className={styles.actions}>
{canAction && (
<>
<button
className={`${styles.btn} ${styles.approveBtn}`}
onClick={() => openModal('approve')}
disabled={isApproving}
>
{isApproving ? '处理中...' : '通过'}
</button>
<button
className={`${styles.btn} ${styles.rejectBtn}`}
onClick={() => openModal('reject')}
disabled={isRejecting}
>
{isRejecting ? '处理中...' : '拒绝'}
</button>
</>
)}
{canCancel && !canAction && (
<div className={styles.actions}>
{canAction && (
<>
<button
className={`${styles.btn} ${styles.cancelBtn}`}
onClick={handleCancel}
disabled={isCancelling}
className={`${styles.btn} ${styles.approveBtn}`}
onClick={handleApprove}
disabled={isApproving}
>
{isCancelling ? '处理中...' : '撤销申请'}
{isApproving ? '处理中...' : '通过'}
</button>
)}
</div>
<Modal
visible={showModal}
title={actionType === 'approve' ? '审批通过' : '审批拒绝'}
onClose={() => setShowModal(false)}
content={
<div className={styles.modalContent}>
<textarea
className={styles.textarea}
placeholder={actionType === 'approve' ? '请输入审批意见(可选)' : '请输入拒绝原因'}
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
/>
</div>
}
actions={[
{
key: 'cancel',
text: '取消',
onClick: () => setShowModal(false),
},
{
key: 'confirm',
text: actionType === 'approve' ? '确认通过' : '确认拒绝',
primary: true,
onClick: actionType === 'approve' ? handleApprove : handleReject,
},
]}
/>
</>
<button
className={`${styles.btn} ${styles.rejectBtn}`}
onClick={handleReject}
disabled={isRejecting}
>
{isRejecting ? '处理中...' : '拒绝'}
</button>
</>
)}
{canCancel && !canAction && (
<button
className={`${styles.btn} ${styles.cancelBtn}`}
onClick={handleCancel}
disabled={isCancelling}
>
{isCancelling ? '处理中...' : '撤销申请'}
</button>
)}
</div>
)
}
@@ -104,6 +104,14 @@
min-height: 32px;
font-size: 12px;
padding: 6px 8px;
border: 1px solid #e5e5e5;
border-radius: 6px;
background: #fff;
}
.field .readonlyInput {
background: #f5f5f5;
color: #666;
}
.dateField {
@@ -127,10 +135,66 @@
min-width: 0;
}
/* 修复:DatePicker 组件不需要 flex 样式,避免影响内部结构 */
.dateField :global(.adm-date-picker) {
flex: 1;
min-width: 0;
display: block;
}
/* DatePicker 弹框样式 - 确保可见 */
:global(.adm-popover) {
z-index: 10000 !important;
}
:global(.adm-date-picker-popover) {
z-index: 10000 !important;
}
:global(.adm-popup) {
z-index: 10000 !important;
}
:global(.adm-mask) {
z-index: 9999 !important;
}
/* Selector 组件样式覆盖 */
.field :global(.adm-selector) {
flex: 1;
min-width: 0;
}
.field :global(.adm-selector .adm-selector-multiple) {
gap: 6px;
}
.field :global(.adm-selector .adm-selector-item) {
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
background: #fff;
color: #333;
transition: all 0.2s;
}
.field :global(.adm-selector .adm-selector-item.adm-selector-item-active) {
background: #1677ff;
border-color: #1677ff;
color: #fff;
}
.field :global(.adm-selector .adm-selector-item.adm-selector-item-disabled) {
background: #f5f5f5;
border-color: #e5e5e5;
color: #999;
}
.dateClickable {
padding: 6px 8px;
border: 1px solid #e5e5e5;
border-radius: 4px;
border-radius: 6px;
background: #fff;
min-height: 32px;
font-size: 12px;
@@ -138,6 +202,29 @@
align-items: center;
cursor: pointer;
color: #333;
user-select: none;
-webkit-tap-highlight-color: transparent;
width: 100%;
text-align: left;
position: relative;
z-index: 1;
/* 覆盖全局 button 样式 */
border: 1px solid #e5e5e5 !important;
background: #fff !important;
padding: 6px 8px !important;
}
.dateClickable:active {
background: #f0f0f0 !important;
}
/* 确保 DatePicker 弹框能正常显示 */
:global(.adm-date-picker-modal) {
z-index: 9999 !important;
}
:global(.adm-popup-body) {
z-index: 9999 !important;
}
.col {
@@ -316,10 +403,11 @@
right: 0;
background: #fff;
padding: 10px 16px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
display: flex;
gap: 12px;
z-index: 100;
z-index: 9999;
}
.saveBtn,
+151 -33
View File
@@ -1,9 +1,10 @@
import { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { useState, useMemo, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { DatePicker, Input, Toast, Dialog, TextArea, Selector } from 'antd-mobile'
import { AddOutline, DeleteOutline } from 'antd-mobile-icons'
import dayjs from 'dayjs'
import { businessTripApi } from '../../services/api'
import styles from './apply.module.css'
interface ItineraryItem {
@@ -12,7 +13,7 @@ interface ItineraryItem {
departure: string
destination: string
transport: string
tripType: 'oneway' | 'roundtrip'
tripType: 'oneway' | 'roundtrip' | ''
remark: string
}
@@ -55,14 +56,41 @@ const transportOptions = [
export default function BusinessTripApply() {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [reason, setReason] = useState('')
const [itineraries, setItineraries] = useState<ItineraryItem[]>([
{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: 'oneway', remark: '' }
{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: '' as 'oneway' | 'roundtrip' | '', remark: '' }
])
const [approvers, setApprovers] = useState<ApproverItem[]>([])
const [ccUsers, setCcUsers] = useState<ApproverItem[]>([])
// DatePicker 可见性状态
const [datePickerVisible, setDatePickerVisible] = useState<{
index: number
field: 'startTime' | 'endTime'
} | null>(null)
// 处理从选择页面返回的数据
useEffect(() => {
// 检查 sessionStorage 中是否有选择的数据
const approversData = sessionStorage.getItem('selectedApprovers')
const ccData = sessionStorage.getItem('selectedCc')
// 从 sessionStorage 恢复状态(组件重新挂载时需要)
if (approversData) {
const approvers = JSON.parse(approversData) as ApproverItem[]
setApprovers(approvers)
console.log('✅ Restored approvers from sessionStorage:', approvers)
}
if (ccData) {
const cc = JSON.parse(ccData) as ApproverItem[]
setCcUsers(cc)
console.log('✅ Restored CC from sessionStorage:', cc)
}
}, []) // 只在组件挂载时执行一次
// 计算总时长(累加所有行程的天数)
const totalDuration = useMemo(() => {
let total = 0
@@ -80,7 +108,7 @@ export default function BusinessTripApply() {
departure: '',
destination: '',
transport: '',
tripType: 'oneway',
tripType: '' as 'oneway' | 'roundtrip' | '',
remark: ''
}])
}
@@ -107,8 +135,47 @@ export default function BusinessTripApply() {
navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } })
}
const handleSave = () => {
Toast.show('草稿已保存')
const handleSave = async () => {
try {
// 验证必填字段
if (!reason.trim()) {
Toast.show(t('businessTrip.pleaseEnterReason'))
return
}
const hasValidItinerary = itineraries.some(item => item.startTime && item.destination.trim())
if (!hasValidItinerary) {
Toast.show('请至少填写一条完整行程')
return
}
// 准备行程数据
const itineraryData = itineraries
.filter(item => item.startTime) // 只保存有开始时间的行程
.map(item => ({
start_time: dayjs(item.startTime).format('YYYY-MM-DD HH:mm:ss'),
end_time: item.endTime ? dayjs(item.endTime).format('YYYY-MM-DD HH:mm:ss') : '',
departure: item.departure || '',
destination: item.destination || '',
transport: item.transport || '',
trip_type: item.tripType || 'oneway',
remark: item.remark || '',
}))
await businessTripApi.create({
reason,
itineraries: itineraryData,
})
// 保存成功后清除 sessionStorage
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
Toast.show('草稿已保存')
navigate('/business-trip')
} catch (error: any) {
Toast.show(error.message || '保存失败')
}
}
const handleSubmit = () => {
@@ -123,11 +190,50 @@ export default function BusinessTripApply() {
return
}
if (approvers.length === 0) {
Toast.show('请选择审批人')
return
}
Dialog.confirm({
content: t('businessTrip.confirmSubmit'),
onConfirm: async () => {
Toast.show(t('businessTrip.submitSuccess'))
navigate('/business-trip')
try {
// 准备行程数据
const itineraryData = itineraries
.filter(item => item.startTime) // 只保存有开始时间的行程
.map(item => ({
start_time: dayjs(item.startTime).format('YYYY-MM-DD HH:mm:ss'),
end_time: item.endTime ? dayjs(item.endTime).format('YYYY-MM-DD HH:mm:ss') : '',
departure: item.departure || '',
destination: item.destination || '',
transport: item.transport || '',
trip_type: item.tripType || 'oneway',
remark: item.remark || '',
}))
// 先创建草稿
const result = await businessTripApi.create({
reason,
itineraries: itineraryData,
})
// 再提交
await businessTripApi.submit(
result.id,
approvers.map(a => a.id),
ccUsers.map(u => u.id)
)
// 提交成功后清除 sessionStorage
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
Toast.show(t('businessTrip.submitSuccess'))
navigate('/business-trip')
} catch (error: any) {
Toast.show(error.message || '提交失败')
}
}
})
}
@@ -165,7 +271,7 @@ export default function BusinessTripApply() {
<div key={index} className={styles.itineraryItem}>
{itineraries.length > 1 && (
<div className={styles.itineraryHeader}>
<span className={styles.itineraryIndex}>{index + 1}</span>
<span className={styles.itineraryIndex}>行程{index + 1}</span>
<button
className={styles.removeBtn}
onClick={() => handleRemoveItinerary(index)}
@@ -177,33 +283,43 @@ export default function BusinessTripApply() {
<div className={styles.dateField}>
<label className={styles.required}>{t('businessTrip.itineraryStartTime')}</label>
<DatePicker
value={item.startTime}
onConfirm={(val) => handleUpdateItinerary(index, 'startTime', val)}
precision="minute"
<div
className={styles.dateClickable}
onClick={() => setDatePickerVisible({ index, field: 'startTime' })}
>
{value => (
<div className={styles.dateClickable}>
{value ? dayjs(value).format('YYYY-MM-DD HH:mm') : t('common.select')}
</div>
)}
</DatePicker>
{item.startTime ? dayjs(item.startTime).format('YYYY-MM-DD HH:mm') : t('common.select')}
</div>
<DatePicker
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'startTime'}
onConfirm={(val) => {
handleUpdateItinerary(index, 'startTime', val)
setDatePickerVisible(null)
}}
onClose={() => setDatePickerVisible(null)}
onCancel={() => setDatePickerVisible(null)}
precision="minute"
/>
</div>
<div className={styles.dateField}>
<label className={styles.required}>{t('businessTrip.itineraryEndTime')}</label>
<div
className={styles.dateClickable}
onClick={() => setDatePickerVisible({ index, field: 'endTime' })}
>
{item.endTime ? dayjs(item.endTime).format('YYYY-MM-DD HH:mm') : t('common.select')}
</div>
<DatePicker
value={item.endTime}
onConfirm={(val) => handleUpdateItinerary(index, 'endTime', val)}
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'endTime'}
onConfirm={(val) => {
handleUpdateItinerary(index, 'endTime', val)
setDatePickerVisible(null)
}}
onClose={() => setDatePickerVisible(null)}
onCancel={() => setDatePickerVisible(null)}
precision="minute"
{...(item.startTime && { min: item.startTime })}
>
{value => (
<div className={styles.dateClickable}>
{value ? dayjs(value).format('YYYY-MM-DD HH:mm') : t('common.select')}
</div>
)}
</DatePicker>
/>
</div>
<div className={styles.field}>
@@ -214,7 +330,7 @@ export default function BusinessTripApply() {
</div>
<div className={styles.field}>
<label>{t('businessTrip.itineraryDeparture')}</label>
<label className={styles.required}>{t('businessTrip.itineraryDeparture')}</label>
<Input
placeholder={t('businessTrip.itineraryDeparture')}
value={item.departure}
@@ -234,20 +350,22 @@ export default function BusinessTripApply() {
</div>
<div className={styles.field}>
<label>{t('businessTrip.itineraryTransport')}</label>
<label className={styles.required}>{t('businessTrip.itineraryTransport')}</label>
<Selector
options={transportOptions}
value={item.transport ? [item.transport] : []}
onChange={(val) => handleUpdateItinerary(index, 'transport', val[0] || '')}
showCheckMark={false}
/>
</div>
<div className={styles.field}>
<label>{t('businessTrip.tripType')}</label>
<label className={styles.required}>{t('businessTrip.tripType')}</label>
<Selector
options={tripTypeOptions}
value={[item.tripType]}
value={item.tripType ? [item.tripType] : []}
onChange={(val) => handleUpdateItinerary(index, 'tripType', val[0] as 'oneway' | 'roundtrip')}
showCheckMark={false}
/>
</div>
@@ -1,39 +1,161 @@
.detail {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 80px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 50vh;
color: #999;
}
.content {
padding: 16px;
}
.card {
background: #fff;
border-radius: 8px;
padding: 16px;
.statusCard {
margin-bottom: 16px;
}
.card h2 {
margin: 0 0 12px 0;
font-size: 16px;
.statusHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.actions {
.reason {
font-size: 18px;
font-weight: 500;
color: #333;
flex: 1;
margin-right: 12px;
}
.statusInfo {
display: flex;
flex-direction: column;
gap: 12px;
}
.infoItem {
display: flex;
}
.label {
color: #999;
font-size: 14px;
width: 80px;
flex-shrink: 0;
}
.value {
color: #333;
font-size: 14px;
flex: 1;
}
.section {
margin-bottom: 16px;
}
.itineraries {
display: flex;
flex-direction: column;
gap: 16px;
}
.itineraryItem {
background: #fafafa;
border-radius: 8px;
padding: 12px;
}
.itineraryHeader {
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.itineraryContent {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
font-size: 14px;
}
.timeline {
padding: 16px 0;
}
.timelineItem {
position: relative;
}
.timelineTitle {
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.timelineTime {
font-size: 12px;
color: #999;
}
.timelineDesc {
font-size: 12px;
color: #666;
}
.timelineIconSuccess {
color: #52c41a;
font-size: 18px;
margin-right: 8px;
}
.timelineIconError {
color: #ff4d4f;
font-size: 18px;
margin-right: 8px;
}
.empty {
text-align: center;
color: #999;
padding: 20px 0;
font-size: 14px;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 12px 16px;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
display: flex;
gap: 12px;
}
.actions button {
.footer button {
flex: 1;
padding: 12px;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.approveBtn {
background: #52c41a;
color: #fff;
:global(.adm-card-body) {
padding: 16px;
}
.rejectBtn {
background: #ff4d4f;
color: #fff;
:global(.adm-card-header) {
padding: 16px 16px 12px;
}
+281 -7
View File
@@ -1,16 +1,290 @@
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 dayjs from 'dayjs'
import styles from './detail.module.css'
// 状态配置
const statusConfig: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
draft: { color: 'default', icon: <ClockCircleOutline />, text: '草稿' },
pending: { color: 'warning', icon: <ExclamationCircleOutline />, text: '审批中' },
approved: { color: 'success', icon: <CheckCircleOutline />, text: '已通过' },
rejected: { color: 'danger', icon: <CloseCircleOutline />, text: '已拒绝' },
cancelled: { color: 'default', icon: <CloseCircleOutline />, text: '已撤销' },
}
interface Itinerary {
id: number
start_time: string
end_time: string
departure: string
destination: string
transport: string
trip_type: string
remark: string
}
interface BusinessTripDetail {
id: number
reason: string
start_date: string
end_date: string
destination: string
status: string
estimated_cost: string
created_at: string
user: {
name: string
phone: string
email: string
}
itineraries: Itinerary[]
}
export default function BusinessTripDetail() {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams()
const [detail, setDetail] = useState<BusinessTripDetail | null>(null)
const [loading, setLoading] = useState(true)
// 加载详情
const loadDetail = async () => {
if (!id) return
setLoading(true)
try {
const data = await businessTripApi.getDetail(Number(id))
setDetail(data)
} catch (error: any) {
Toast.show(error.message || '加载失败')
navigate(-1)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadDetail()
}, [id])
// 撤销申请
const handleCancel = () => {
Dialog.confirm({
content: '确定要撤销此申请吗?',
onConfirm: async () => {
try {
await businessTripApi.cancel(Number(id))
Toast.show('已撤销')
loadDetail()
} catch (error: any) {
Toast.show(error.message || '操作失败')
}
},
})
}
// 编辑申请
const handleEdit = () => {
navigate(`/business-trip/apply?id=${id}`)
}
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 calculateDays = (start: string, end: string) => {
const startDate = new Date(start)
const endDate = new Date(end)
const diff = endDate.getTime() - startDate.getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24)) + 1
}
// 格式化时间
const formatTime = (timeStr: string) => {
return dayjs(timeStr).format('YYYY-MM-DD HH:mm')
}
// 格式化日期
const formatDate = (dateStr: string) => {
return dayjs(dateStr).format('YYYY-MM-DD')
}
return (
<div className={styles.detail}>
<div className={styles.card}>
<h2>出差申请详情</h2>
<p>详情内容...</p>
</div>
<div className={styles.actions}>
<button className={styles.approveBtn}>通过</button>
<button className={styles.rejectBtn}>拒绝</button>
<NavBar onBack={() => navigate(-1)}>出差申请详情</NavBar>
<div className={styles.content}>
{/* 状态卡片 */}
<Card className={styles.statusCard}>
<div className={styles.statusHeader}>
<span className={styles.reason}>{detail.reason}</span>
<Tag color={config.color}>
{config.icon}
{config.text}
</Tag>
</div>
<div className={styles.statusInfo}>
<div className={styles.infoItem}>
<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>
</div>
<div className={styles.infoItem}>
<span className={styles.label}>返回日期:</span>
<span className={styles.value}>{formatDate(detail.end_date)}</span>
</div>
<div className={styles.infoItem}>
<span className={styles.label}>出差天数:</span>
<span className={styles.value}>{calculateDays(detail.start_date, detail.end_date)} 天</span>
</div>
<div className={styles.infoItem}>
<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>
{/* 行程明细 */}
<Card title="行程明细" className={styles.section}>
{detail.itineraries && detail.itineraries.length > 0 ? (
<div className={styles.itineraries}>
{detail.itineraries.map((item, index) => (
<div key={item.id} className={styles.itineraryItem}>
<div className={styles.itineraryHeader}>行程 {index + 1}</div>
<div className={styles.itineraryContent}>
<div className={styles.row}>
<span className={styles.label}>出发时间:</span>
<span className={styles.value}>{formatTime(item.start_time)}</span>
</div>
{item.end_time && (
<div className={styles.row}>
<span className={styles.label}>结束时间:</span>
<span className={styles.value}>{formatTime(item.end_time)}</span>
</div>
)}
<div className={styles.row}>
<span className={styles.label}>出发地:</span>
<span className={styles.value}>{item.departure || '-'}</span>
</div>
<div className={styles.row}>
<span className={styles.label}>目的地:</span>
<span className={styles.value}>{item.destination}</span>
</div>
<div className={styles.row}>
<span className={styles.label}>交通工具:</span>
<span className={styles.value}>{item.transport || '-'}</span>
</div>
<div className={styles.row}>
<span className={styles.label}>行程类型:</span>
<span className={styles.value}>{item.trip_type === 'roundtrip' ? '往返' : '单程'}</span>
</div>
{item.remark && (
<div className={styles.row}>
<span className={styles.label}>备注:</span>
<span className={styles.value}>{item.remark}</span>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className={styles.empty}>暂无行程明细</div>
)}
</Card>
{/* 审批进度 */}
<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>
)}
</div>
</Card>
</div>
{/* 底部操作栏 */}
{(canEdit || canCancel) && (
<div className={styles.footer}>
{canEdit && (
<Button block color="primary" onClick={handleEdit}>
编辑申请
</Button>
)}
{canCancel && (
<Button block color="warning" onClick={handleCancel}>
撤销申请
</Button>
)}
</div>
)}
</div>
)
}
@@ -1,32 +1,97 @@
.list {
padding: 16px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab {
flex: 1;
padding: 8px 12px;
border: none;
min-height: 100vh;
background: #f5f5f5;
border-radius: 6px;
padding-bottom: 60px;
}
.item {
padding: 12px 16px !important;
}
.card {
background: #fff;
border-radius: 8px;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.reason {
font-size: 16px;
font-weight: 500;
color: #333;
flex: 1;
margin-right: 12px;
}
.content {
display: flex;
flex-direction: column;
gap: 8px;
}
.info {
display: flex;
align-items: center;
gap: 16px;
}
.date {
font-size: 14px;
color: #666;
cursor: pointer;
}
.tab.active {
background: #1677ff;
color: #fff;
.days {
font-size: 14px;
color: #999;
}
.destination {
font-size: 14px;
color: #666;
}
.applicant {
font-size: 14px;
color: #999;
}
.empty {
padding: 60px 0;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: #999;
}
.empty p {
margin: 16px 0 24px;
font-size: 14px;
}
.fab {
position: fixed;
right: 20px;
bottom: 80px;
z-index: 100;
}
.fab button {
width: 50px;
height: 50px;
border-radius: 50%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
:global(.adm-tabs-header) {
background: #fff;
position: sticky;
top: 0;
z-index: 10;
}
+177 -5
View File
@@ -1,14 +1,186 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { List, Card, Tag, Button, Tabs, PullToRefresh, InfiniteScroll } from 'antd-mobile'
import { AddOutline, ClockCircleOutline, CheckCircleOutline, CloseCircleOutline, ExclamationCircleOutline } from 'antd-mobile-icons'
import { businessTripApi } from '../../services/api'
import styles from './list.module.css'
// 状态映射
const statusConfig: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
draft: { color: 'default', icon: <ClockCircleOutline />, text: '草稿' },
pending: { color: 'warning', icon: <ExclamationCircleOutline />, text: '审批中' },
approved: { color: 'success', icon: <CheckCircleOutline />, text: '已通过' },
rejected: { color: 'danger', icon: <CloseCircleOutline />, text: '已拒绝' },
cancelled: { color: 'default', icon: <CloseCircleOutline />, text: '已撤销' },
}
interface BusinessTripItem {
id: number
reason: string
start_date: string
end_date: string
destination: string
status: string
created_at: string
user?: {
name: string
}
}
export default function BusinessTripList() {
const { t } = useTranslation()
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<'my' | 'pending' | 'approved'>('my')
const [list, setList] = useState<BusinessTripItem[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
// 加载数据
const loadData = async (pageNum: number = 1, isRefresh: boolean = false) => {
if (loading) return
setLoading(true)
try {
const result = await businessTripApi.getList({
type: activeTab,
page: pageNum,
page_size: 20,
})
const newList = result.list || []
if (isRefresh || pageNum === 1) {
setList(newList)
} else {
setList([...list, ...newList])
}
setHasMore(newList.length >= 20)
setPage(pageNum)
} catch (error: any) {
console.error('Failed to load business trips:', error)
} finally {
setLoading(false)
}
}
// 切换标签
const handleTabChange = (key: string) => {
setActiveTab(key as 'my' | 'pending' | 'approved')
setList([])
setHasMore(true)
setPage(1)
loadData(1, true)
}
// 刷新
const handleRefresh = async () => {
await loadData(1, true)
}
// 加载更多
const handleLoadMore = async () => {
await loadData(page + 1, false)
}
// 初始加载
useEffect(() => {
loadData(1, true)
}, [])
// 查看详情
const handleViewDetail = (id: number) => {
navigate(`/business-trip/detail/${id}`)
}
// 新建申请
const handleCreate = () => {
navigate('/business-trip/apply')
}
// 格式化日期
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
}
// 计算天数
const calculateDays = (start: string, end: string) => {
const startDate = new Date(start)
const endDate = new Date(end)
const diff = endDate.getTime() - startDate.getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24)) + 1
}
return (
<div className={styles.list}>
<div className={styles.tabs}>
<button className={`${styles.tab} ${styles.active}`}>我发起的</button>
<button className={styles.tab}>待我审批</button>
<button className={styles.tab}>我已审批</button>
<Tabs activeKey={activeTab} onChange={handleTabChange}>
<Tabs.Tab title="我发起的" key="my" />
<Tabs.Tab title="待我审批" key="pending" />
<Tabs.Tab title="我已审批" key="approved" />
</Tabs>
<PullToRefresh onRefresh={handleRefresh}>
<List>
{list.map((item) => {
const config = statusConfig[item.status] || statusConfig.draft
const days = calculateDays(item.start_date, item.end_date)
return (
<List.Item
key={item.id}
onClick={() => handleViewDetail(item.id)}
className={styles.item}
>
<Card className={styles.card}>
<div className={styles.header}>
<span className={styles.reason}>{item.reason}</span>
<Tag color={config.color}>
{config.icon}
{config.text}
</Tag>
</div>
<div className={styles.content}>
<div className={styles.info}>
<span className={styles.date}>
{formatDate(item.start_date)} - {formatDate(item.end_date)}
</span>
<span className={styles.days}>{days}天</span>
</div>
<div className={styles.destination}>目的地: {item.destination}</div>
{item.user && activeTab !== 'my' && (
<div className={styles.applicant}>申请人: {item.user.name}</div>
)}
</div>
</Card>
</List.Item>
)
})}
</List>
</PullToRefresh>
{list.length === 0 && !loading && (
<div className={styles.empty}>
<ExclamationCircleOutline fontSize={48} color="#cccccc" />
<p>暂无出差申请</p>
{activeTab === 'my' && (
<Button color="primary" size="small" onClick={handleCreate}>
<AddOutline /> 发起申请
</Button>
)}
</div>
)}
<InfiniteScroll loadMore={handleLoadMore} hasMore={hasMore} />
<div className={styles.fab}>
<Button color="primary" onClick={handleCreate}>
<AddOutline fontSize={24} />
</Button>
</div>
<div className={styles.empty}>暂无出差申请</div>
</div>
)
}
@@ -1,19 +1,30 @@
.page {
min-height: 100vh;
height: 100vh;
max-height: 100vh;
max-width: 100vw;
width: 100%;
background: #f5f5f5;
display: flex;
flex-direction: column;
overflow: hidden;
}
.navBar {
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
width: 100%;
z-index: 10;
}
.selectedSection {
background: #fff;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
width: 100%;
max-width: 100%;
z-index: 9;
}
.selectedHeader {
@@ -51,10 +62,12 @@
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
max-width: 100%;
}
.selectedUser {
display: flex;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
@@ -66,6 +79,7 @@
width: 20px;
height: 20px;
border-radius: 50%;
flex-shrink: 0;
}
.selectedName {
@@ -73,6 +87,25 @@
color: #333;
}
.removeBtn {
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.1);
color: #666;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.removeBtn:active {
background: rgba(0, 0, 0, 0.2);
}
.emptyText {
font-size: 13px;
color: #999;
@@ -82,12 +115,22 @@
background: #fff;
padding: 8px 16px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
width: 100%;
z-index: 8;
}
.userList {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
padding-bottom: 70px;
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
.userItem {
@@ -99,16 +142,26 @@
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.userItem:active {
background: #f5f5f5;
}
.userItem.selected {
background: #e6f7ff;
}
.userInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.avatar {
@@ -116,32 +169,70 @@
height: 44px;
border-radius: 50%;
background: #f0f0f0;
flex-shrink: 0;
}
.userDetails {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.name {
font-size: 15px;
color: #333;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email {
font-size: 13px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.position {
font-size: 13px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.checkIcon {
color: #1677ff;
font-size: 20px;
flex-shrink: 0;
}
.loadMore {
padding: 16px;
text-align: center;
color: #1677ff;
font-size: 14px;
cursor: pointer;
}
.loadMore:active {
opacity: 0.7;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
border-top: 1px solid #f0f0f0;
z-index: 999;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
}
@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { NavBar, SearchBar, Checkbox, Button, Toast, Empty } from 'antd-mobile'
import { NavBar, SearchBar, Button, Toast, Empty } from 'antd-mobile'
import { CheckOutline } from 'antd-mobile-icons'
import { userApi } from '../../services/api'
import styles from './select-approver.module.css'
interface UserItem {
@@ -10,6 +11,8 @@ interface UserItem {
name: string
avatar?: string
position?: string
email?: string
phone?: string
}
interface LocationState {
@@ -17,6 +20,13 @@ interface LocationState {
type: 'approver' | 'cc'
}
interface ApiResponse {
list: UserItem[]
total: number
page: number
page_size: number
}
export default function SelectUserPage() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -24,24 +34,69 @@ export default function SelectUserPage() {
const state = location.state as LocationState
const [searchValue, setSearchValue] = useState('')
const [selectedUsers, setSelectedUsers] = useState<UserItem[]>(state?.selected || [])
// 优先从 location.state 获取,否则从 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 [selectedUsers, setSelectedUsers] = useState<UserItem[]>(getInitialSelected())
const [allUsers, setAllUsers] = useState<UserItem[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
// Mock data - 模拟用户数据
// 加载用户列表
const loadUsers = async (pageNum: number = 1, isRefresh: boolean = false) => {
if (loading) return
setLoading(true)
try {
const result: ApiResponse = await userApi.getUsers({
keyword: searchValue,
page: pageNum,
page_size: 50,
})
const newList = result.list || []
if (isRefresh || pageNum === 1) {
setAllUsers(newList)
} else {
setAllUsers([...allUsers, ...newList])
}
setHasMore(newList.length >= 50)
setPage(pageNum)
} catch (error: any) {
Toast.show(error.message || '加载用户列表失败')
} finally {
setLoading(false)
}
}
// 搜索用户
useEffect(() => {
setAllUsers([
{ id: 1, name: '张三', position: '部门主管', avatar: '/default-avatar.png' },
{ id: 2, name: '李四', position: '人事经理', avatar: '/default-avatar.png' },
{ id: 3, name: '王五', position: '总经理', avatar: '/default-avatar.png' },
{ id: 4, name: '赵六', position: '财务总监', avatar: '/default-avatar.png' },
{ id: 5, name: '孙七', position: '部门主管', avatar: '/default-avatar.png' },
{ id: 6, name: '周八', position: '人事专员', avatar: '/default-avatar.png' },
])
}, [])
const timeoutId = setTimeout(() => {
setAllUsers([])
setPage(1)
setHasMore(true)
loadUsers(1, true)
}, 300)
const filteredUsers = allUsers.filter(user =>
user.name.toLowerCase().includes(searchValue.toLowerCase())
)
return () => clearTimeout(timeoutId)
}, [searchValue])
// 初始加载
useEffect(() => {
loadUsers(1, true)
}, [])
const isUserSelected = (userId: number) => {
return selectedUsers.some(u => u.id === userId)
@@ -56,17 +111,18 @@ export default function SelectUserPage() {
}
const handleConfirm = () => {
if (state?.type === 'approver') {
// TODO: 保存选中的审批人并返回
} else {
// TODO: 保存选中的抄送人并返回
// 根据类型使用不同的 key 存储数据
const type = location.state?.type || 'approver'
if (type === 'approver') {
sessionStorage.setItem('selectedApprovers', JSON.stringify(selectedUsers))
} else if (type === 'cc') {
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
}
navigate(-1)
}
const handleAddApprover = () => {
// 审批人页面特有的添加功能
Toast.show('添加自定义审批人')
Toast.show('添加自定义审批人功能开发中')
}
return (
@@ -75,16 +131,15 @@ export default function SelectUserPage() {
onBack={() => navigate(-1)}
className={styles.navBar}
>
{state?.type === 'approver' ? t('businessTrip.approvers') : t('businessTrip.cc')}
{state?.type === 'approver' ? '选择审批人' : '选择抄送人'}
</NavBar>
{state?.type === 'approver' && (
<div className={styles.selectedSection}>
<div className={styles.selectedHeader}>
<span className={styles.selectedTitle}>已选审批人</span>
<button className={styles.addBtn} onClick={handleAddApprover}>
+
</button>
<span className={styles.selectedTitle}>
已选{selectedUsers.length > 0 && ` (${selectedUsers.length})`}
</span>
</div>
<div className={styles.selectedList}>
{selectedUsers.length > 0 ? (
@@ -96,6 +151,12 @@ export default function SelectUserPage() {
className={styles.selectedAvatar}
/>
<span className={styles.selectedName}>{user.name}</span>
<button
className={styles.removeBtn}
onClick={() => handleToggleUser(user)}
>
×
</button>
</div>
))
) : (
@@ -107,51 +168,61 @@ export default function SelectUserPage() {
<div className={styles.searchSection}>
<SearchBar
placeholder={t('common.search')}
placeholder="搜索用户姓名"
value={searchValue}
onChange={setSearchValue}
/>
</div>
<div className={styles.userList}>
{filteredUsers.length > 0 ? (
filteredUsers.map(user => (
<div
key={user.id}
className={styles.userItem}
onClick={() => handleToggleUser(user)}
>
<div className={styles.userInfo}>
<img
src={user.avatar || '/default-avatar.png'}
alt={user.name}
className={styles.avatar}
/>
<div className={styles.userDetails}>
<span className={styles.name}>{user.name}</span>
{user.position && (
<span className={styles.position}>{user.position}</span>
)}
{allUsers.length > 0 ? (
<>
{allUsers.map(user => (
<div
key={user.id}
className={`${styles.userItem} ${isUserSelected(user.id) ? styles.selected : ''}`}
onClick={() => handleToggleUser(user)}
>
<div className={styles.userInfo}>
<img
src={user.avatar || '/default-avatar.png'}
alt={user.name}
className={styles.avatar}
/>
<div className={styles.userDetails}>
<span className={styles.name}>{user.name}</span>
{user.position && (
<span className={styles.position}>{user.position}</span>
)}
{user.email && (
<span className={styles.email}>{user.email}</span>
)}
</div>
</div>
{isUserSelected(user.id) && (
<CheckOutline className={styles.checkIcon} />
)}
</div>
{isUserSelected(user.id) && (
<CheckOutline className={styles.checkIcon} />
)}
</div>
))
))}
{hasMore && (
<div className={styles.loadMore} onClick={() => loadUsers(page + 1)}>
加载更多...
</div>
)}
</>
) : (
<Empty description={t('common.noData')} />
<Empty description={loading ? '加载中...' : '暂无用户数据'} />
)}
</div>
<div className={styles.footer}>
<Button
block
type="primary"
color="primary"
onClick={handleConfirm}
disabled={selectedUsers.length === 0}
>
{t('common.confirm')} ({selectedUsers.length})
确定 ({selectedUsers.length})
</Button>
</div>
</div>
@@ -1,25 +1,42 @@
.page {
min-height: 100vh;
height: 100vh;
max-height: 100vh;
max-width: 100vw;
width: 100%;
background: #f5f5f5;
display: flex;
flex-direction: column;
overflow: hidden;
}
.navBar {
background: #fff;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
width: 100%;
z-index: 10;
}
.searchSection {
background: #fff;
padding: 8px 16px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
width: 100%;
z-index: 8;
}
.userList {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
padding-bottom: 70px;
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
.userItem {
@@ -31,17 +48,26 @@
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.userItem:active {
background: #f5f5f5;
}
.userItem.selected {
background: #e6f7ff;
}
.userInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.avatar {
@@ -49,32 +75,77 @@
height: 44px;
border-radius: 50%;
background: #f0f0f0;
flex-shrink: 0;
}
.userDetails {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.name {
font-size: 15px;
color: #333;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email {
font-size: 13px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.position {
font-size: 13px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.checkIcon {
color: #1677ff;
font-size: 20px;
flex-shrink: 0;
}
.checkbox {
--size: 20px;
--icon-size: 14px;
flex-shrink: 0;
}
.loadMore {
padding: 16px;
text-align: center;
color: #1677ff;
font-size: 14px;
cursor: pointer;
flex-shrink: 0;
}
.loadMore:active {
opacity: 0.7;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
border-top: 1px solid #f0f0f0;
z-index: 999;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
}
+105 -44
View File
@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { NavBar, SearchBar, Checkbox, Button, Empty } from 'antd-mobile'
import { NavBar, SearchBar, Button, Toast, Empty } from 'antd-mobile'
import { CheckOutline } from 'antd-mobile-icons'
import { userApi } from '../../services/api'
import styles from './select-cc.module.css'
interface UserItem {
@@ -9,6 +11,8 @@ interface UserItem {
name: string
avatar?: string
position?: string
email?: string
phone?: string
}
interface LocationState {
@@ -16,6 +20,13 @@ interface LocationState {
type: 'approver' | 'cc'
}
interface ApiResponse {
list: UserItem[]
total: number
page: number
page_size: number
}
export default function SelectCcPage() {
const { t } = useTranslation()
const navigate = useNavigate()
@@ -23,41 +34,80 @@ export default function SelectCcPage() {
const state = location.state as LocationState
const [searchValue, setSearchValue] = useState('')
const [selectedUsers, setSelectedUsers] = useState<Set<number>>(
new Set(state?.selected?.map(u => u.id) || [])
)
// 优先从 location.state 获取,否则从 sessionStorage 获取
const getInitialSelected = () => {
if (state?.selected) return state.selected
const data = sessionStorage.getItem('selectedCc')
return data ? JSON.parse(data) : []
}
const [selectedUsers, setSelectedUsers] = useState<UserItem[]>(getInitialSelected())
const [allUsers, setAllUsers] = useState<UserItem[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
// Mock data - 模拟用户数据
// 加载用户列表
const loadUsers = async (pageNum: number = 1, isRefresh: boolean = false) => {
if (loading) return
setLoading(true)
try {
const result: ApiResponse = await userApi.getUsers({
keyword: searchValue,
page: pageNum,
page_size: 50,
})
const newList = result.list || []
if (isRefresh || pageNum === 1) {
setAllUsers(newList)
} else {
setAllUsers([...allUsers, ...newList])
}
setHasMore(newList.length >= 50)
setPage(pageNum)
} catch (error: any) {
Toast.show(error.message || '加载用户列表失败')
} finally {
setLoading(false)
}
}
// 搜索用户
useEffect(() => {
setAllUsers([
{ id: 1, name: '张三', position: '部门主管', avatar: '/default-avatar.png' },
{ id: 2, name: '李四', position: '人事经理', avatar: '/default-avatar.png' },
{ id: 3, name: '王五', position: '总经理', avatar: '/default-avatar.png' },
{ id: 4, name: '赵六', position: '财务总监', avatar: '/default-avatar.png' },
{ id: 5, name: '孙七', position: '部门主管', avatar: '/default-avatar.png' },
{ id: 6, name: '周八', position: '人事专员', avatar: '/default-avatar.png' },
])
const timeoutId = setTimeout(() => {
setAllUsers([])
setPage(1)
setHasMore(true)
loadUsers(1, true)
}, 300)
return () => clearTimeout(timeoutId)
}, [searchValue])
// 初始加载
useEffect(() => {
loadUsers(1, true)
}, [])
const filteredUsers = allUsers.filter(user =>
user.name.toLowerCase().includes(searchValue.toLowerCase())
)
const isUserSelected = (userId: number) => {
return selectedUsers.some(u => u.id === userId)
}
const handleToggleUser = (userId: number) => {
const newSelected = new Set(selectedUsers)
if (newSelected.has(userId)) {
newSelected.delete(userId)
const handleToggleUser = (user: UserItem) => {
if (isUserSelected(user.id)) {
setSelectedUsers(selectedUsers.filter(u => u.id !== user.id))
} else {
newSelected.add(userId)
setSelectedUsers([...selectedUsers, user])
}
setSelectedUsers(newSelected)
}
const handleConfirm = () => {
const selected = allUsers.filter(u => selectedUsers.has(u.id))
// TODO: 保存选中的抄送人并返回
navigate(-1, { selected })
// 使用 sessionStorage 来传递数据
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
navigate(-1)
}
return (
@@ -66,25 +116,26 @@ export default function SelectCcPage() {
onBack={() => navigate(-1)}
className={styles.navBar}
>
{t('businessTrip.cc')}
选择抄送人
</NavBar>
<div className={styles.searchSection}>
<SearchBar
placeholder={t('common.search')}
placeholder="搜索用户姓名"
value={searchValue}
onChange={setSearchValue}
/>
</div>
<div className={styles.userList}>
<Checkbox.Group
value={Array.from(selectedUsers)}
onChange={val => setSelectedUsers(new Set(val as number[]))}
>
{filteredUsers.length > 0 ? (
filteredUsers.map(user => (
<label key={user.id} className={styles.userItem}>
{allUsers.length > 0 ? (
<>
{allUsers.map(user => (
<div
key={user.id}
className={`${styles.userItem} ${isUserSelected(user.id) ? styles.selected : ''}`}
onClick={() => handleToggleUser(user)}
>
<div className={styles.userInfo}>
<img
src={user.avatar || '/default-avatar.png'}
@@ -96,25 +147,35 @@ export default function SelectCcPage() {
{user.position && (
<span className={styles.position}>{user.position}</span>
)}
{user.email && (
<span className={styles.email}>{user.email}</span>
)}
</div>
</div>
<Checkbox value={user.id} className={styles.checkbox} />
</label>
))
) : (
<Empty description={t('common.noData')} />
)}
</Checkbox.Group>
{isUserSelected(user.id) && (
<CheckOutline className={styles.checkIcon} />
)}
</div>
))}
{hasMore && (
<div className={styles.loadMore} onClick={() => loadUsers(page + 1)}>
加载更多...
</div>
)}
</>
) : (
<Empty description={loading ? '加载中...' : '暂无用户数据'} />
)}
</div>
<div className={styles.footer}>
<Button
block
type="primary"
color="primary"
onClick={handleConfirm}
disabled={selectedUsers.size === 0}
disabled={selectedUsers.length === 0}
>
{t('common.confirm')} ({selectedUsers.size})
确定 ({selectedUsers.length})
</Button>
</div>
</div>
+80 -1
View File
@@ -1,11 +1,90 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Toast } from 'antd-mobile'
import styles from './login.module.css'
export default function Login() {
const navigate = useNavigate()
useEffect(() => {
// 检查 URL 中是否有 auth_code 参数(飞书授权回调)
const urlParams = new URLSearchParams(window.location.search)
const authCode = urlParams.get('auth_code')
if (authCode) {
// 有授权码,执行登录
handleFeishuLogin(authCode)
} else {
// 没有授权码,跳转到飞书授权页面
redirectToFeishu()
}
}, [])
const redirectToFeishu = () => {
// 飞书应用配置
const appId = 'cli_a8033d607cba500d'
// 当前页面 URL 作为回调地址
const redirectUri = encodeURIComponent(window.location.origin + '/login')
// 租户密钥(新友合公司)
const tenantKey = '14e7bc0fe5985740'
// 构建飞书授权 URL
const authUrl = `https://open.feishu.cn/open-apis/authen/v1/oidc/authorize?app_id=${appId}&redirect_uri=${redirectUri}&scope=&state=${tenantKey}`
// 跳转到飞书授权页面
window.location.href = authUrl
}
const handleFeishuLogin = async (authCode: string) => {
try {
const tenantKey = '14e7bc0fe5985740'
// 调用后端登录接口
const response = await fetch('/api/v1/auth/feishu-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
auth_code: authCode,
tenant_key: tenantKey,
}),
})
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 || '登录失败')
// 登录失败,重新跳转到飞书授权
setTimeout(() => {
redirectToFeishu()
}, 2000)
}
} catch (error: any) {
console.error('登录失败:', error)
Toast.show('登录失败,请重试')
setTimeout(() => {
redirectToFeishu()
}, 2000)
}
}
return (
<div className={styles.login}>
<div className={styles.container}>
<h1>企业工具台</h1>
<button className={styles.feishuBtn}>飞书授权登录</button>
<p>正在跳转到飞书授权...</p>
<button className={styles.feishuBtn} onClick={redirectToFeishu}>
重新跳转飞书登录
</button>
</div>
</div>
)
+76 -73
View File
@@ -1,99 +1,102 @@
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, type AxiosResponse } from 'axios'
import { API_BASE_URL, API_TIMEOUT } from '@/config'
import axios from 'axios'
// 请求响应接口
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 请求配置接口
export interface RequestConfig extends InternalAxiosRequestConfig {
skipAuth?: boolean
}
const API_BASE = '/api/v1'
// 创建 axios 实例
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: API_TIMEOUT,
const api = axios.create({
baseURL: API_BASE,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
apiClient.interceptors.request.use(
(config: RequestConfig) => {
// 从 localStorage 获取 token
const token = localStorage.getItem('access_token')
if (token && !config.skipAuth) {
// 请求拦截器:添加 token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
apiClient.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
// 响应拦截器:处理错误
api.interceptors.response.use(
(response) => {
const { data } = response
if (data.code === 0) {
return response
return data.data
}
// 处理业务错误
return Promise.reject(new Error(data.message || 'Request failed'))
return Promise.reject(new Error(data.message || '请求失败'))
},
(error: AxiosError<ApiResponse>) => {
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
// 未授权,跳转登录
localStorage.removeItem('access_token')
window.location.href = '/login'
break
case 403:
console.error('Forbidden:', data?.message)
break
case 404:
console.error('Not Found:', data?.message)
break
case 500:
console.error('Server Error:', data?.message)
break
default:
console.error('HTTP Error:', status, data?.message)
}
} else if (error.request) {
console.error('Network Error:', error.message)
} else {
console.error('Error:', error.message)
}
return Promise.reject(error)
(error) => {
const message = error.response?.data?.message || error.message || '网络错误'
return Promise.reject(new Error(message))
}
)
// API 方法封装
export const api = {
get: <T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> => {
return apiClient.get(url, config).then(res => res.data)
},
// ========== 认证 API ==========
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
post: <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
return apiClient.post(url, data, config).then(res => res.data)
},
put: <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
return apiClient.put(url, data, config).then(res => res.data)
},
delete: <T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> => {
return apiClient.delete(url, config).then(res => res.data)
},
feishuLogin: (authCode: string, tenantKey: string) =>
api.post('/auth/feishu-login', { auth_code: authCode, tenant_key: tenantKey }),
}
export default apiClient
// ========== 用户 API ==========
export const userApi = {
getInfo: () => api.get('/user/info'),
getMenu: () => api.get('/user/menu'),
getCompanies: () => api.get('/user/companies'),
switchCompany: (companyId: number) =>
api.post('/user/switch-company', { company_id: companyId }),
// 获取用户列表
getUsers: (params: { keyword?: string; page?: number; page_size?: number }) =>
api.get('/user/list', { params }),
}
// ========== 出差申请 API ==========
export const businessTripApi = {
getList: (params: { type?: string; page?: number; page_size?: number }) =>
api.get('/business-trip/list', { params }),
getDetail: (id: number) => api.get(`/business-trip/detail/${id}`),
create: (data: any) => api.post('/business-trip/create', data),
update: (id: number, data: any) =>
api.post(`/business-trip/update/${id}`, data),
submit: (id: number, approvers: number[], ccUsers: number[] = []) =>
api.post(`/business-trip/submit/${id}`, {
approvers,
cc_users: ccUsers,
}),
cancel: (id: number) => api.post(`/business-trip/cancel/${id}`),
}
// ========== 审批 API ==========
export const approvalApi = {
getList: (params: { type?: string; page?: number; page_size?: number }) =>
api.get('/approval/list', { params }),
getDetail: (id: number) => api.get(`/approval/detail/${id}`),
approve: (id: number, comment?: string) =>
api.post(`/approval/approve/${id}`, { comment }),
reject: (id: number, comment?: string) =>
api.post(`/approval/reject/${id}`, { comment }),
cancel: (id: number) => api.post(`/approval/cancel/${id}`),
}
export default api
+8
View File
@@ -13,6 +13,14 @@ export default defineConfig({
server: {
port: 5173,
host: '0.0.0.0',
strictPort: false,
// 允许公网域名访问
allowedHosts: [
'xsm.dev.hnxinyouhe.com',
'.dev.hnxinyouhe.com', // 允许所有 dev.hnxinyouhe.com 的子域名
'localhost',
'127.0.0.1'
],
proxy: {
'/api': {
target: 'http://127.0.0.1:8787',
+37 -26
View File
@@ -2,38 +2,53 @@
# 使用方法: frpc -c frpc-dev.ini
[common]
# ======== 请修改以下配置 ========
server_addr = your-frp-server.com
server_port = 7000
token = your-frp-token
# ================================
serverAddr = "8.152.213.143"
serverPort = 7000
# 开发环境 API 代理
[x-server-api-dev]
type = http
local_ip = 127.0.0.1
local_port = 8080
custom_domains = api-dev.x-server.your-domain.com
auth.method = "token"
auth.token = "6dEjuXXMpJSdBbAQDRzECHmAbeGd"
# X-Server 开发环境代理
[x-server-dev]
type = "http"
localIP = "127.0.0.1"
localPort = 80
# 自定义域名方式(不需要 frps 配置 subdomainHost)
customDomains = ["xsm.dev.hnxinyouhe.com"]
# 启用加密和压缩
use_encryption = true
use_compression = true
transport.useEncryption = true
transport.useCompression = true
# 健康检查
health_check_type = http
health_check_url = /health
health_check_interval_s = 30
health_check_max_failed = 3
healthCheck.type = "tcp"
healthCheck.intervalS = 30
healthCheck.maxFailed = 3
# ========================================
# 配置说明
# ========================================
#
# 1. 修改 server_addr 为你的 frp 服务器地址
# 2. 修改 token 为你的 frp 服务器 token
# 3. 修改 custom_domains 为你的开发域名
# 4. 确保 local_port (8080) 与 OpenResty 配置一致
# 5. 将 api-dev.x-server.your-domain.com 的 DNS 指向 frp 服务器
# frps 服务器配置:
# - 地址: 8.152.213.143:7000
# - HTTP 端口: 8080
# - HTTPS 端口: 8443
#
# 访问方式(二选一):
#
# 方式1: 使用 subdomain(推荐,无需 DNS 配置)
# 访问: http://x-server-dev.8.152.213.143:8080
#
# 方式2: 使用自定义域名
# 1. 修改 customDomains
# 2. 将域名 A 记录指向 8.152.213.143
# 3. 访问: http://your-domain.com
#
# 本地服务端口:
# - OpenResty: 80 (frpc 指向这里)
# - 前端: http://127.0.0.1:5173
# - 后端: http://127.0.0.1:8787
#
# 启动命令:
# frpc -c frpc-dev.ini
@@ -41,7 +56,3 @@ health_check_max_failed = 3
# 后台运行:
# nohup frpc -c frpc-dev.ini > /dev/null 2>&1 &
#
# 使用 systemd 管理 (推荐):
# sudo cp frpc-dev.service /etc/systemd/system/
# sudo systemctl enable frpc-dev
# sudo systemctl start frpc-dev
+30
View File
@@ -0,0 +1,30 @@
serverAddr = "8.152.213.143"
serverPort = 7000
auth.method = "token"
auth.token = "6dEjuXXMpJSdBbAQDRzECHmAbeGd"
webServer.addr = "0.0.0.0"
webServer.port = 7500
webServer.user = "admin_mzl92"
webServer.password = "ydd6Gx7cuA8XwQ38JSpxhpSBHVGy"
webServer.pprofEnable = false
# X-Server 开发环境代理
[[proxies]]
name = "x-server-dev"
type = "http"
localIP = "127.0.0.1"
localPort = 80
# 自定义域名
customDomains = ["x-server.dev.hnxinyouhe.com"]
# 启用加密和压缩
transport.useEncryption = true
transport.useCompression = true
# 健康检查
healthCheck.type = "tcp"
healthCheck.intervalS = 30
healthCheck.maxFailed = 3
-11
View File
@@ -35,14 +35,3 @@ server {
add_header Content-Type text/plain;
}
}
# HTTPS 配置(用于生产环境,需要配合 SSL 证书)
# server {
# listen 443 ssl http2;
# server_name api.x-server.com;
#
# ssl_certificate /path/to/cert.pem;
# ssl_certificate_key /path/to/key.pem;
#
# # ... 其他配置同上
# }