fix: 修复页面间状态丢失问题并优化选择页面
- 修复选择审批人后再选择抄送人导致状态丢失的问题 - 使用独立的 sessionStorage key (selectedApprovers, selectedCc) - 组件每次挂载时从 sessionStorage 恢复状态 - 只在提交/保存成功后清除 sessionStorage - 优化人员选择页面布局和样式 - 更新 CLAUDE.md 文档记录问题和解决方案 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
-8
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 多语言翻译类
|
||||
@@ -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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Bootstrap\Database::class,
|
||||
];
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
#
|
||||
# # ... 其他配置同上
|
||||
# }
|
||||
|
||||
Reference in New Issue
Block a user