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:
@@ -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}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user