feat: 审批功能优化和拒绝原因弹窗

后端改动:
- ApprovalService: 添加调试日志,简化详情页 URL 生成逻辑
- BusinessTripService: 优化出差审批详情展示

前端改动:
- approval/detail: 审批详情页优化,支持查看完整审批流程
- approval/reject: 新增拒绝原因弹窗组件
- business-trip/detail: 出差详情页增强,展示审批状态和操作

功能特性:
- 审批详情页显示完整审批流程和记录
- 拒绝操作支持填写拒绝原因
- 出差申请详情页优化展示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-27 17:57:56 +08:00
parent b1ca2c58b7
commit 1004aea725
5 changed files with 329 additions and 31 deletions
+5 -12
View File
@@ -72,9 +72,12 @@ class ApprovalService
}
$currentNodeIds = $order->current_nodes ?? [];
error_log("🔍 当前节点 IDs: " . json_encode($currentNodeIds));
$currentNode = ApprovalNode::whereIn('id', $currentNodeIds)->first();
if (!$currentNode) {
error_log("❌ 未找到节点,IDs: " . json_encode($currentNodeIds));
throw new \Exception('当前节点不存在');
}
@@ -501,18 +504,8 @@ class ApprovalService
{
$baseUrl = 'https://xsm.dev.hnxinyouhe.com';
switch ($order->business_type) {
case 'business_trip':
return "{$baseUrl}/business-trip/detail/{$order->business_id}";
case 'expense':
return "{$baseUrl}/expense/detail/{$order->business_id}";
case 'leave':
return "{$baseUrl}/leave/detail/{$order->business_id}";
case 'purchase':
return "{$baseUrl}/purchase/detail/{$order->business_id}";
default:
return "{$baseUrl}/approval/detail/{$order->id}";
}
// 统一使用审批详情页,不再跳转到具体业务模块的详情页
return "{$baseUrl}/approval/detail/{$order->id}";
}
/**
+15 -7
View File
@@ -295,12 +295,9 @@ class BusinessTripService
return;
}
// 构建审批链接(使用 approval_order 的 ID)
// 构建审批链接(统一使用审批详情页)
$approvalUrl = "https://xsm.dev.hnxinyouhe.com/approval/detail/{$approvalOrder->id}";
// 构建业务详情链接(用于查看详情按钮)
$detailUrl = "https://xsm.dev.hnxinyouhe.com/business-trip/detail/{$businessTrip->id}";
// 构建内容
$content = "**出差原因**: {$businessTrip->reason}\n";
$content .= "**目的地**: {$businessTrip->destination}\n";
@@ -317,7 +314,7 @@ class BusinessTripService
'applicant' => $businessTrip->user->name,
'type' => '出差申请',
'content' => $content,
'url' => $detailUrl,
'url' => $approvalUrl,
'order_id' => $approvalOrder->id, // 添加 order_id 以显示审批按钮
]);
error_log("✅ 审批人通知成功: approver_id={$approverId}");
@@ -356,7 +353,16 @@ class BusinessTripService
{
try {
$notificationService = new \app\service\FeishuNotificationService();
$detailUrl = "https://xsm.dev.hnxinyouhe.com/business-trip/detail/{$businessTrip->id}";
// 获取关联的审批订单
$approvalOrder = \app\model\ApprovalOrder::where('business_id', $businessTrip->id)
->where('business_type', 'business_trip')
->first();
// 统一使用审批详情页
$detailUrl = $approvalOrder
? "https://xsm.dev.hnxinyouhe.com/approval/detail/{$approvalOrder->id}"
: "https://xsm.dev.hnxinyouhe.com/approval/list";
if ($action === 'approve') {
$title = '出差申请已通过';
@@ -642,7 +648,9 @@ class BusinessTripService
{
try {
$notificationService = new \app\service\FeishuNotificationService();
$detailUrl = "https://xsm.dev.hnxinyouhe.com/business-trip/detail/{$businessTrip->id}";
// 统一使用审批详情页
$detailUrl = "https://xsm.dev.hnxinyouhe.com/approval/detail/{$approvalOrder->id}";
// 获取审批人和抄送人
$formData = $approvalOrder->form_data ?? [];
+22 -6
View File
@@ -135,17 +135,22 @@ export default function ApprovalDetail() {
return
}
const appId = 'cli_a8033d607cba500d'
// 从 URL 获取 company 参数,不需要 appId(后端会自动识别)
const urlParams = new URLSearchParams(window.location.search)
const companyParam = urlParams.get('company')
console.log('🔍 URL 中的 company 参数:', companyParam)
const requestAuthCode = (apiObj: any, source: string) => {
console.log(`🚀 使用 ${source} 调用 requestAuthCode`)
// 不传 appId,让飞书自动选择(后端会根据 company 参数处理)
apiObj.requestAuthCode({
appId: appId,
success: async (res: any) => {
console.log(`✅ ${source}.requestAuthCode 成功:`, res)
console.log('🔑 authCode:', res.code?.substring(0, 30) + '...')
try {
console.log('📡 调用后端登录API...')
const result = await authApi.feishuLogin(res.code)
// 传递 company 参数
const result = await authApi.feishuLogin(res.code, companyParam || undefined)
console.log('✅ 后端登录成功:', result.user?.name)
login(result.user, result.token)
Toast.show('登录成功')
@@ -243,14 +248,18 @@ export default function ApprovalDetail() {
if (!success) {
console.log('❌ 免登失败,跳转登录页')
Toast.show('免登失败,请使用其他方式登录')
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname + window.location.search)
// 保留 company 参数
const currentUrl = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentUrl)
return
}
console.log('✅ 免登成功,继续加载详情')
} else {
console.log('⚠️ 不在飞书内或 tt 对象不可用')
Toast.show('请先登录')
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname + window.location.search)
// 保留 company 参数
const currentUrl = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentUrl)
return
}
} else {
@@ -676,13 +685,19 @@ export default function ApprovalDetail() {
{(canCancel || canApprove) && (
<div className={styles.footer}>
{canCancel && (
<Button block color="warning" onClick={handleCancel}>
<Button
key="cancel"
block
color="warning"
onClick={handleCancel}
>
撤销申请
</Button>
)}
{canApprove && (
<>
<Button
key="reject"
block
color="danger"
fill="outline"
@@ -691,6 +706,7 @@ export default function ApprovalDetail() {
拒绝
</Button>
<Button
key="approve"
block
color="primary"
onClick={() => handleApprove('approve')}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Input, Button, Toast, Space } from 'antd-mobile'
import { approvalApi } from '@/services/api'
export default function RejectPage() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const orderId = searchParams.get('order_id')
const from = searchParams.get('from')
const [reason, setReason] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
console.log('Reject page loaded:', { orderId, from })
// 如果没有 order_id,返回列表
if (!orderId) {
Toast.show('参数错误')
navigate('/approval', { replace: true })
}
}, [orderId, from, navigate])
const handleReject = async () => {
if (!reason.trim()) {
Toast.show('请输入拒绝原因')
return
}
setLoading(true)
try {
const result = await approvalApi.reject(Number(orderId), reason)
if (result) {
Toast.show('已拒绝')
// 如果是从飞书打开,尝试关闭页面
if (from === 'feishu') {
setTimeout(() => {
try {
if ((window as any).tt && typeof (window as any).tt.close === 'function') {
(window as any).tt.close()
} else {
navigate('/approval', { replace: true })
}
} catch (e) {
navigate('/approval', { replace: true })
}
}, 1500)
} else {
navigate('/approval', { replace: true })
}
}
} catch (error: any) {
Toast.show('操作失败: ' + (error.message || '未知错误'))
} finally {
setLoading(false)
}
}
const handleCancel = () => {
navigate(-1)
}
return (
<div style={{ padding: '20px', minHeight: '100vh', background: '#f5f5f5' }}>
<div style={{ background: '#fff', borderRadius: '8px', padding: '20px' }}>
<h3 style={{ marginBottom: '20px' }}>拒绝申请</h3>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<div style={{ marginBottom: '8px', color: '#666' }}>拒绝原因</div>
<Input
placeholder="请输入拒绝原因"
value={reason}
onChange={setReason}
rows={4}
maxLength={200}
showCount
/>
</div>
<div style={{ display: 'flex', gap: '10px', marginTop: '20px' }}>
<Button
color="danger"
onClick={handleReject}
loading={loading}
style={{ flex: 1 }}
>
确认拒绝
</Button>
<Button
color="default"
onClick={handleCancel}
style={{ flex: 1 }}
>
取消
</Button>
</div>
</Space>
</div>
</div>
)
}
+183 -6
View File
@@ -10,11 +10,19 @@ import {
ExclamationCircleOutline,
DeleteOutline
} from 'antd-mobile-icons'
import { businessTripApi, userApi } from '../../services/api'
import { businessTripApi, userApi, authApi } from '../../services/api'
import { useUserStore } from '@/stores/user'
import dayjs from 'dayjs'
import styles from './detail.module.css'
// 声明飞书 JSAPI 类型
declare global {
interface Window {
tt?: any
ttjsBridge?: any
}
}
// 状态配置
const statusConfig: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
draft: { color: 'default', icon: <ClockCircleOutline />, text: '草稿' },
@@ -78,31 +86,171 @@ export default function BusinessTripDetail() {
const { t } = useTranslation()
const navigate = useNavigate()
const { id } = useParams()
const { user } = useUserStore()
const { user, login } = useUserStore()
const [detail, setDetail] = useState<BusinessTripDetail | null>(null)
const [loading, setLoading] = useState(true)
const [commentVisible, setCommentVisible] = useState(false)
const [comment, setComment] = useState('')
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve')
const [tryingFeishuLogin, setTryingFeishuLogin] = useState(false)
// 飞书免登
const tryFeishuLogin = (): Promise<boolean> => {
return new Promise((resolve) => {
const ua = navigator.userAgent
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
console.log('🔍 tryFeishuLogin 开始')
console.log('📱 isInFeishu:', isInFeishu)
if (!isInFeishu) {
console.log('❌ 不在飞书内,跳过免登')
resolve(false)
return
}
// 从 URL 获取 company 参数
const urlParams = new URLSearchParams(window.location.search)
const companyParam = urlParams.get('company')
console.log('🔍 URL 中的 company 参数:', companyParam)
const requestAuthCode = (apiObj: any, source: string) => {
console.log(`🚀 使用 ${source} 调用 requestAuthCode`)
// 不传 appId,让飞书自动选择
apiObj.requestAuthCode({
success: async (res: any) => {
console.log(`✅ ${source}.requestAuthCode 成功:`, res)
console.log('🔑 authCode:', res.code?.substring(0, 30) + '...')
try {
console.log('📡 调用后端登录API...')
const result = await authApi.feishuLogin(res.code, companyParam || undefined)
console.log('✅ 后端登录成功:', result.user?.name)
login(result.user, result.token)
Toast.show('登录成功')
resolve(true)
} catch (error) {
console.error('❌ 飞书登录API失败:', error)
Toast.show('登录失败: ' + (error.message || '未知错误'))
resolve(false)
}
},
fail: (error: any) => {
console.error(`❌ ${source}.requestAuthCode 失败:`, error)
Toast.show('免登失败: ' + (error.errorMessage || error.errMsg || '未知错误'))
resolve(false)
},
})
}
// 优先使用 tt 对象
if (window.tt && typeof window.tt.requestAuthCode === 'function') {
requestAuthCode(window.tt, 'tt')
return
}
// 尝试使用其他方式
if (window.ttjsBridge && typeof window.ttjsBridge.requestAuthCode === 'function') {
requestAuthCode(window.ttjsBridge, 'ttjsBridge')
return
}
// 所有方法都不可用
console.log('❌ 所有飞书 API 都不可用')
Toast.show('飞书API不可用,请使用其他方式登录')
resolve(false)
})
}
// 加载详情
const loadDetail = async () => {
if (!id) return
console.log('📋 loadDetail 开始, id:', id)
console.log('📋 当前 URL:', window.location.href)
// 检查登录状态
const token = localStorage.getItem('token')
const isLoggedIn = !!user
console.log('🔐 登录状态检查:', { isLoggedIn: !!isLoggedIn, hasToken: !!token, userId: user?.id })
if (!isLoggedIn && !token) {
console.log('⚠️ 用户未登录,检查飞书环境')
const ua = navigator.userAgent
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
const currentUrl = window.location.href
const urlParams = new URLSearchParams(window.location.search)
console.log('📱 飞书环境检测:', { isInFeishu, ua: ua.substring(0, 100) })
// 在飞书中但 tt 对象不存在时,刷新页面
if (isInFeishu && !window.tt) {
const reloadCount = parseInt(urlParams.get('reload') || '0')
if (reloadCount < 3) {
console.log(`⚠️ 在飞书中但 tt 对象不存在,第 ${reloadCount + 1} 次刷新页面`)
const newUrl = currentUrl.split('?')[0] + '?' + urlParams.toString().replace(/reload=\d+/g, '').replace(/^&+/, '')
window.location.href = newUrl + (newUrl.includes('?') ? '&' : '?') + `reload=${reloadCount + 1}`
return
}
}
if (isInFeishu && window.tt && !tryingFeishuLogin) {
console.log('🚀 开始飞书免登...')
setTryingFeishuLogin(true)
const success = await tryFeishuLogin()
setTryingFeishuLogin(false)
console.log('✅ 免登结果:', success)
if (!success) {
console.log('❌ 免登失败,跳转登录页')
// 保留完整 URL 参数
window.location.href = '/login?redirect=' + encodeURIComponent(currentUrl)
return
}
console.log('✅ 免登成功,继续加载详情')
} else {
console.log('⚠️ 不在飞书内或 tt 对象不可用')
Toast.show('请先登录')
const redirectUrl = '/login?redirect=' + encodeURIComponent(currentUrl)
console.log('🚨 准备跳转到登录页 (非飞书环境), redirectUrl:', redirectUrl)
window.location.href = redirectUrl
return
}
} else {
console.log('✅ 用户已登录,直接加载详情')
}
console.log('🔄 开始加载详情...')
setLoading(true)
try {
const data = await businessTripApi.getDetail(Number(id))
console.log('✅ 详情加载成功:', data)
setDetail(data)
} catch (error: any) {
Toast.show(error.message || '加载失败')
navigate(-1)
console.error('❌ 详情加载失败:', error)
// 如果是 401 错误,handleUnauthorized 会处理跳转
if (error.message !== '登录已过期') {
Toast.show(error.message || '加载失败')
navigate(-1)
}
} finally {
setLoading(false)
}
}
useEffect(() => {
console.log('🔍 BusinessTripDetail mount, id:', id)
console.log('🔍 User:', user)
console.log('🔍 Token:', localStorage.getItem('token')?.substring(0, 20) + '...')
// 检查飞书环境
const ua = navigator.userAgent
const isInFeishu = ua.includes('Lark') || ua.includes('feishu') || ua.includes('lark')
console.log('🔍 User Agent:', ua)
console.log('🔍 在飞书中:', isInFeishu)
console.log('🔍 window.tt:', window.tt)
loadDetail()
}, [id])
@@ -217,6 +365,22 @@ export default function BusinessTripDetail() {
<div className={styles.detail}>
<NavBar onBack={() => navigate(-1)}>出差申请详情</NavBar>
{/* 调试信息 */}
{process.env.NODE_ENV === 'development' && (
<div style={{
background: '#f0f0f0',
padding: '8px',
fontSize: '12px',
fontFamily: 'monospace',
borderBottom: '1px solid #ddd'
}}>
<div>🔍 登录状态: {user ? `${user.name} (id:${user.id}, company:${user.company_id})` : '未登录'}</div>
<div>📱 在飞书中: {(navigator.userAgent.includes('Lark') || navigator.userAgent.includes('feishu') || navigator.userAgent.includes('lark')) ? '是' : '否'}</div>
<div>🔧 tt对象: {window.tt ? '存在' : '不存在'}</div>
<div>📍 URL参数: {new URLSearchParams(window.location.search).get('company') || '无'}</div>
</div>
)}
<div className={styles.content}>
{/* 状态卡片 */}
<Card className={styles.statusCard}>
@@ -370,6 +534,7 @@ export default function BusinessTripDetail() {
<div className={styles.footer}>
{canDelete && (
<Button
key="delete"
block
color="danger"
fill="outline"
@@ -380,18 +545,29 @@ export default function BusinessTripDetail() {
</Button>
)}
{canEdit && (
<Button block color="primary" onClick={handleEdit}>
<Button
key="edit"
block
color="primary"
onClick={handleEdit}
>
编辑申请
</Button>
)}
{canCancel && (
<Button block color="warning" onClick={handleCancel}>
<Button
key="cancel"
block
color="warning"
onClick={handleCancel}
>
撤销申请
</Button>
)}
{canApprove && (
<>
<Button
key="reject"
block
color="danger"
fill="outline"
@@ -400,6 +576,7 @@ export default function BusinessTripDetail() {
拒绝
</Button>
<Button
key="approve"
block
color="primary"
onClick={() => handleApprove('approve')}