diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index fca0d14..c211434 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -3,8 +3,10 @@ import bcrypt from 'bcryptjs' import multer from 'multer' import path from 'path' import fs from 'fs' +import jwt from 'jsonwebtoken' import prisma from '../lib/prisma' import { signAccessToken, verifyAccessToken } from '../lib/jwt' +import { authMiddleware, AuthRequest } from '../middleware/auth' import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema' import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore' import { createEvidence } from '../services/evidence.service' @@ -544,4 +546,69 @@ router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response } }) +// ========== 一次性自动登录 ========== + +const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret' + +/** + * 管理端生成员工一次性自动登录 token(10 分钟有效) + * POST /portal/auto-login-token body: { employeeId } + */ +router.post('/auto-login-token', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { employeeId } = req.body + if (!employeeId) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } }) + } + const employee = await prisma.employee.findFirst({ + where: { id: employeeId, orgId: req.user!.orgId, status: 'ACTIVE' }, + select: { id: true, name: true, phone: true, orgId: true }, + }) + if (!employee) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } }) + } + const token = jwt.sign( + { id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name }, + AUTO_LOGIN_SECRET, + { expiresIn: '10m' } + ) + res.json({ success: true, data: { token, employeeName: employee.name, phone: employee.phone } }) + } catch (err) { + next(err) + } +}) + +/** + * 员工端自动登录(消费一次性 token) + * GET /portal/auto-login?token=xxx + */ +router.get('/auto-login', async (req, res, next) => { + try { + const { token } = req.query + if (!token || typeof token !== 'string') { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 token' } }) + } + let payload: any + try { + payload = jwt.verify(token, AUTO_LOGIN_SECRET) + } catch { + return res.status(401).json({ success: false, error: { code: 'TOKEN_EXPIRED', message: '链接已过期,请重新扫码' } }) + } + if (payload.role !== 'EMPLOYEE_AUTO') { + return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '无效的登录链接' } }) + } + const employee = await prisma.employee.findFirst({ + where: { id: payload.id, orgId: payload.orgId, status: 'ACTIVE' }, + select: { id: true, name: true, department: true, orgId: true }, + }) + if (!employee) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } }) + } + const accessToken = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' }) + res.json({ success: true, data: { token: accessToken, employee: { id: employee.id, name: employee.name, department: employee.department } } }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/frontend/index.html b/frontend/index.html index 8fde364..f713138 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + 企业用工专家 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f1eae8..462a340 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { useAuthStore } from './store/authStore' import TopNav from './components/layout/TopNav' import SidebarNav from './components/layout/SidebarNav' import MobileTabBar from './components/layout/MobileTabBar' +import PortalLayout from './components/layout/PortalLayout' import PageContainer from './components/layout/PageContainer' import { SkeletonPage } from './components/ui/Skeleton' import OnboardingGuide from './components/OnboardingGuide' @@ -31,6 +32,7 @@ const MyContract = lazy(() => import('./pages/portal/MyContract')) const Onboarding = lazy(() => import('./pages/portal/Onboarding')) const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm')) const MyPolicies = lazy(() => import('./pages/portal/MyPolicies')) +const AutoLogin = lazy(() => import('./pages/portal/AutoLogin')) const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator')) const HealthCheck = lazy(() => import('./pages/tools/HealthCheck')) const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) @@ -66,13 +68,20 @@ function AdminLayout({ children }: { children: React.ReactNode }) { ) } -function PortalLayout({ children }: { children: React.ReactNode }) { +function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) { + if (!showNav) { + return ( +
+
+ }>{children} +
+
+ ) + } return ( -
-
- }>{children} -
-
+ + }>{children} + ) } @@ -104,12 +113,13 @@ export default function App() { } /> {/* 员工端 */} - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 兜底 */} } /> diff --git a/frontend/src/components/PortalQRModal.tsx b/frontend/src/components/PortalQRModal.tsx new file mode 100644 index 0000000..bf2bf4d --- /dev/null +++ b/frontend/src/components/PortalQRModal.tsx @@ -0,0 +1,219 @@ +/** + * 员工端二维码弹窗 — 展示员工端登录二维码,支持选择员工生成一次性自动登录链接 + */ + +import { useState, useEffect } from 'react' +import { QRCodeSVG } from 'qrcode.react' +import { Smartphone, Copy, Check, Search, Loader2, User, ChevronRight } from 'lucide-react' +import Modal from './ui/Modal' +import api from '../lib/api' + +interface Employee { + id: string + name: string + phone: string + department: string | null +} + +export default function PortalQRModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const [copied, setCopied] = useState(false) + const [employees, setEmployees] = useState([]) + const [loading, setLoading] = useState(false) + const [search, setSearch] = useState('') + const [selectedEmployee, setSelectedEmployee] = useState(null) + const [autoLoginUrl, setAutoLoginUrl] = useState('') + const [generating, setGenerating] = useState(false) + + const portalUrl = `${window.location.origin}/portal/login` + + /** 加载员工列表 */ + useEffect(() => { + if (!open) return + setLoading(true) + api.get('/roster', { params: { pageSize: 999 } }).then((res: any) => { + const list = res.data?.data || res.data || [] + setEmployees(list.map((e: any) => ({ + id: e.id, + name: e.name, + phone: e.phone, + department: e.department, + }))) + }).catch(() => {}).finally(() => setLoading(false)) + }, [open]) + + /** 选择员工后生成一次性自动登录链接 */ + const handleSelectEmployee = async (emp: Employee) => { + setSelectedEmployee(emp) + setGenerating(true) + setAutoLoginUrl('') + try { + const res = await api.post('/portal/auto-login-token', { employeeId: emp.id }) as any + const token = res.data?.token + if (token) { + setAutoLoginUrl(`${window.location.origin}/portal/auto-login?token=${token}`) + } + } catch { + setAutoLoginUrl(portalUrl) + } finally { + setGenerating(false) + } + } + + const handleCopy = () => { + navigator.clipboard.writeText(autoLoginUrl || portalUrl) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const handleReset = () => { + setSelectedEmployee(null) + setAutoLoginUrl('') + setSearch('') + } + + const filteredEmployees = search + ? employees.filter(e => e.name.includes(search) || e.phone.includes(search)) + : employees + + const displayUrl = autoLoginUrl || portalUrl + + return ( + { handleReset(); onClose() }} title="员工端入口" size="sm"> +
+ {/* 未选择员工时:展示员工列表 */} + {!selectedEmployee ? ( + <> +
+ + 选择员工后生成专属登录二维码 +
+ + {/* 搜索框 */} +
+ + setSearch(e.target.value)} + placeholder="搜索姓名或手机号..." + className="w-full pl-9 pr-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {/* 员工列表 */} +
+ {loading ? ( +
+ +
+ ) : filteredEmployees.length === 0 ? ( +
未找到员工
+ ) : ( + filteredEmployees.map(emp => ( + + )) + )} +
+ + ) : ( + /* 已选择员工:展示二维码 */ + <> + {/* 员工信息 + 重选按钮 */} +
+
+
+ +
+
+
{selectedEmployee.name}
+
{selectedEmployee.phone}
+
+
+ +
+ + {/* 二维码 */} +
+
+ {generating ? ( +
+ +
+ ) : ( + + )} +
+ + {/* 提示信息 */} + {autoLoginUrl ? ( +

+ + 扫码后自动登录,链接 10 分钟内有效 +

+ ) : ( +

员工用手机扫码即可进入员工端

+ )} + + {/* 链接地址 */} +
+
+ {displayUrl} + +
+
+
+ + {/* 功能说明 */} +
+

+ + 查看工资条并确认 +

+

+ + 查看劳动合同信息 +

+

+ + 阅读并签收规章制度 +

+
+ + )} +
+
+ ) +} diff --git a/frontend/src/components/layout/PortalLayout.tsx b/frontend/src/components/layout/PortalLayout.tsx new file mode 100644 index 0000000..ab917a6 --- /dev/null +++ b/frontend/src/components/layout/PortalLayout.tsx @@ -0,0 +1,83 @@ +/** + * 员工端统一布局 — 顶部 Header(Logo + 员工名 + 退出)+ 底部固定 TabBar 导航 + * 所有已登录员工端页面共享此布局 + */ + +import { Link, useLocation, useNavigate } from 'react-router-dom' +import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react' +import Logo from '../../components/ui/Logo' + +const tabItems = [ + { path: '/portal/payslip', label: '工资条', icon: DollarSign }, + { path: '/portal/contract', label: '我的合同', icon: FileText }, + { path: '/portal/policies', label: '规章制度', icon: ScrollText }, +] + +export default function PortalLayout({ children }: { children: React.ReactNode }) { + const location = useLocation() + const navigate = useNavigate() + + const employee = (() => { + try { return JSON.parse(localStorage.getItem('portalEmployee') || '{}') } catch { return {} } + })() + + const handleLogout = () => { + localStorage.removeItem('portalToken') + localStorage.removeItem('portalEmployee') + navigate('/portal/login') + } + + return ( +
+ {/* 顶部 Header — 安全区 padding 适配刘海屏 */} +
+
+
+ + 企业用工专家 +
+
+ {employee.name && ( + {employee.name} + )} + +
+
+
+ + {/* 主内容区 */} +
+ {children} +
+ + {/* 底部固定 TabBar — 安全区 padding 适配 home indicator */} + +
+ ) +} diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 1a3c5e3..242f267 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -15,6 +15,7 @@ import { ChevronDown, ChevronRight, Building2, } from 'lucide-react' +import Logo from '../ui/Logo' interface NavItem { path: string @@ -120,7 +121,7 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea > {/* Logo 区 */}
- + 企业用工专家
diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx index c80e5e2..19973bd 100644 --- a/frontend/src/components/layout/TopNav.tsx +++ b/frontend/src/components/layout/TopNav.tsx @@ -1,17 +1,19 @@ import { Link, useNavigate } from 'react-router-dom' -import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle } from 'lucide-react' +import { ChevronDown, Settings as SettingsIcon, Bell, Menu, HelpCircle, Smartphone } from 'lucide-react' import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../../store/authStore' import api from '../../lib/api' import Breadcrumb from './Breadcrumb' import HelpModal from '../HelpModal' +import PortalQRModal from '../PortalQRModal' export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) { const navigate = useNavigate() const { user, logout } = useAuthStore() const [menuOpen, setMenuOpen] = useState(false) const [helpOpen, setHelpOpen] = useState(false) + const [portalQROpen, setPortalQROpen] = useState(false) const { data: dashboardData } = useQuery({ queryKey: ['dashboard'], @@ -38,8 +40,16 @@ export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) { - {/* 右侧:帮助 + 通知 + 设置 + 用户菜单 */} + {/* 右侧:员工端 + 帮助 + 通知 + 设置 + 用户菜单 */}
+ + setPortalQROpen(false)} /> + + )} +
+ + ) +} diff --git a/frontend/src/pages/portal/MyContract.tsx b/frontend/src/pages/portal/MyContract.tsx index 148fb2c..709edb3 100644 --- a/frontend/src/pages/portal/MyContract.tsx +++ b/frontend/src/pages/portal/MyContract.tsx @@ -1,13 +1,12 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Link } from 'react-router-dom' -import { FileText, AlertCircle, Check, RefreshCw } from 'lucide-react' +import { FileText, AlertCircle, Check, RefreshCw, Calendar, Briefcase, Clock } from 'lucide-react' import api from '../../lib/api' import Card from '../../components/ui/Card' import Button from '../../components/ui/Button' import EmptyState from '../../components/ui/EmptyState' -// 金额格式化:保留两位小数 + 千分位 +/** 金额格式化:保留两位小数 + 千分位 */ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const portalApi = api.create({ baseURL: '/api/v1/portal' }) @@ -29,7 +28,6 @@ export default function MyContract() { }, }) - const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}') const contract = data const daysToExpire = contract?.endDate @@ -52,79 +50,110 @@ export default function MyContract() { } return ( -
-
-
-
- -

我的劳动合同

+
+ {/* 页面标题 */} +

我的劳动合同

+ + {isLoading ? ( + +
+
+
+
+
-
- {employee.name} - 工资条 -
-
- - - {isLoading ? ( -
加载中...
- ) : !contract ? ( - - ) : ( -
- {/* 到期提醒 */} - {daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && ( -
- - 您的合同还有 {daysToExpire} 天到期 -
- )} - -
- - - {contract.signDate && } - - {contract.endDate && } - {contract.contractYears > 0 && } - {contract.probationMonths > 0 && } - {contract.probationSalary > 0 && } -
- - {/* 签署确认记录 */} -
-

签署记录

- {isConfirmed ? ( -
- - 已确认签署({new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()}) -
- ) : ( -
-
- - 合同尚未确认签署 -
- - {resendMsg &&
{resendMsg}
} -
- )} -
+ + ) : !contract ? ( + + + + ) : ( + <> + {/* 到期提醒横幅 */} + {daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && ( +
+ + 您的合同还有 {daysToExpire} 天到期,请关注续签事宜
)} - -
+ + {/* 合同概览卡片 */} + +
+
+ +
+
+
+ {contract.contractType === 'FIXED' ? '固定期限劳动合同' : contract.contractType === 'UNFIXED' ? '无固定期限劳动合同' : '未签订'} +
+
{contract.signMethod === 'PAPER' ? '纸质合同' : '电子合同'}
+
+
+ +
+ + {contract.endDate && ( + + )} + {contract.contractYears > 0 && ( + + )} + {contract.signDate && ( + + )} + {contract.probationMonths > 0 && ( + + )} + {contract.probationSalary > 0 && ( + + )} +
+
+ + {/* 签署确认记录 */} + +

签署确认

+ {isConfirmed ? ( +
+
+ +
+
+
已确认签署
+
+ {new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()} +
+
+
+ ) : ( +
+
+ + 合同尚未确认签署 +
+ + {resendMsg &&
{resendMsg}
} +
+ )} +
+ + )}
) } -function Row({ label, value }: { label: string; value: string }) { +function InfoRow({ icon: Icon, label, value }: { icon: any; label: string; value: string }) { return ( -
- {label} - {value} +
+
+ + {label} +
+ {value}
) } diff --git a/frontend/src/pages/portal/MyPolicies.tsx b/frontend/src/pages/portal/MyPolicies.tsx index f215487..7ad32a2 100644 --- a/frontend/src/pages/portal/MyPolicies.tsx +++ b/frontend/src/pages/portal/MyPolicies.tsx @@ -5,12 +5,11 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { FileText, CheckCircle, Clock, ArrowLeft, ChevronRight } from 'lucide-react' +import { FileText, CheckCircle, Clock, ArrowLeft, ChevronRight, ScrollText } from 'lucide-react' import api from '../../lib/api' import Card from '../../components/ui/Card' import Button from '../../components/ui/Button' import EmptyState from '../../components/ui/EmptyState' -import PortalNav from './PortalNav' const portalApi = api.create({ baseURL: '/api/v1/portal' }) portalApi.interceptors.request.use((config: any) => { @@ -52,109 +51,151 @@ export default function MyPolicies() { }, }) + /** 待签收数量 */ + const pendingCount = (list || []).filter((p: any) => !p.hasRead).length + + /** 详情页 */ if (selectedId) { return ( -
-
- - +
+ - {detailLoading ? ( -
加载中...
- ) : detail ? ( - -
+ {detailLoading ? ( + +
+
+
+
+
+ + ) : detail ? ( + +
+
-

{detail.title}

-
- +
+

{detail.title}

+
公示时间:{detail.publishedAt?.slice(0, 10) || '-'} +
+
+
+ + {/* 签收状态徽章 */} +
+ {detail.hasRead ? ( + + 已签收 - {detail.hasRead ? ( - - 已签收 - - ) : ( - - 待签收 - - )} -
-
- {detail.content || '暂无内容'} -
-
- {detail.hasRead ? ( -
- 您已于 {detail.readAt?.slice(0, 19).replace('T', ' ')} 签收确认 -
- ) : ( - - )} -
- - ) : ( + ) : ( + + 待签收 + + )} +
+ + {/* 制度正文 */} +
+ {detail.content || '暂无内容'} +
+ + {/* 签收操作 */} +
+ {detail.hasRead ? ( +
+ 您已于 {detail.readAt?.slice(0, 19).replace('T', ' ')} 签收确认 +
+ ) : ( + + )} +
+
+ ) : ( + - )} -
+ + )}
) } + /** 列表页 */ return ( -
-
- -
- -

规章制度

-
- - {isLoading ? ( -
加载中...
- ) : !list || list.length === 0 ? ( - - ) : ( -
- {list.map((p: any) => ( - -
setSelectedId(p.id)} className="flex items-center justify-between"> -
-
- {p.title} - {p.hasRead ? ( - - 已签收 - - ) : ( - - 待签收 - - )} -
-
- 公示时间:{p.publishedAt?.slice(0, 10) || '-'} -
-
- -
-
- ))} -
+
+ {/* 页面标题 */} +
+

规章制度

+ {pendingCount > 0 && ( + + + {pendingCount} 项待签收 + )}
+ + {isLoading ? ( +
+ {[1, 2, 3].map(i => ( + +
+
+
+
+
+
+
+ + ))} +
+ ) : !list || list.length === 0 ? ( + + + + ) : ( +
+ {list.map((p: any) => ( + +
setSelectedId(p.id)} className="flex items-center gap-3 p-4"> +
+ +
+
+
+ {p.title} + {p.hasRead ? ( + + 已签收 + + ) : ( + + 待签收 + + )} +
+
+ 公示时间:{p.publishedAt?.slice(0, 10) || '-'} +
+
+ +
+
+ ))} +
+ )}
) } diff --git a/frontend/src/pages/portal/Payslip.tsx b/frontend/src/pages/portal/Payslip.tsx index c818153..9a67518 100644 --- a/frontend/src/pages/portal/Payslip.tsx +++ b/frontend/src/pages/portal/Payslip.tsx @@ -1,15 +1,15 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Link } from 'react-router-dom' -import { DollarSign, Check, TrendingUp, Download } from 'lucide-react' +import { Check, TrendingUp, Download, Wallet, ChevronLeft, ChevronRight } from 'lucide-react' import api from '../../lib/api' import Card from '../../components/ui/Card' import Button from '../../components/ui/Button' import EmptyState from '../../components/ui/EmptyState' -// 金额格式化:保留两位小数 + 千分位 +/** 金额格式化:保留两位小数 + 千分位 */ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +/** 员工端 API 实例(自动携带 portalToken) */ const portalApi = api.create({ baseURL: '/api/v1/portal' }) portalApi.interceptors.request.use((config: any) => { const token = localStorage.getItem('portalToken') @@ -45,16 +45,19 @@ export default function Payslip() { const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}') + /** 月份切换 */ + const handleMonthChange = (delta: number) => { + const d = new Date(month + '-01') + d.setMonth(d.getMonth() + delta) + setMonth(d.toISOString().slice(0, 7)) + } + + /** 导出 CSV */ const handleExport = () => { if (!history || history.length === 0) return const headers = ['月份', '基本工资', '加班费', '津贴', '扣款', '应发合计', '确认状态'] const rows = history.map((p: any) => [ - p.month, - p.baseSalary, - p.overtimePay, - p.allowance, - p.deduction, - p.totalPay, + p.month, p.baseSalary, p.overtimePay, p.allowance, p.deduction, p.totalPay, p.confirmedAt ? '已确认' : '未确认', ]) const csv = [headers, ...rows].map(r => r.join(',')).join('\n') @@ -67,120 +70,145 @@ export default function Payslip() { URL.revokeObjectURL(url) } - const sortedHistory = [...(history || [])].sort((a: any, b: any) => a.month.localeCompare(b.month)) + const sortedHistory = [...(history || [])].sort((a: any, b: any) => b.month.localeCompare(a.month)) const maxPay = Math.max(...sortedHistory.map((p: any) => Number(p.totalPay) || 0), 1) - return ( -
-
-
-
- -

我的工资条

-
-
- {employee.name} - 我的合同 -
-
+ /** 工资明细行 */ + const SalaryRow = ({ label, value, danger }: { label: string; value: number; danger?: boolean }) => { + if (!value || value === 0) return null + return ( +
+ {label} + + {danger ? '-' : ''}¥{fmt(Math.abs(Number(value)))} + +
+ ) + } -
- setMonth(e.target.value)} - className="px-3 py-2 rounded-md border border-gray-300 text-sm" - /> + return ( +
+ {/* 页面标题 */} +
+

我的工资条

+
+
- {showHistory && sortedHistory.length > 0 && ( - -

近 {sortedHistory.length} 个月工资趋势

-
- {sortedHistory.map((p: any) => ( -
- {p.month} -
-
+ {/* 月份选择器 */} +
+ + {month.replace('-', '年')}月 + +
+ + {/* 趋势图 */} + {showHistory && sortedHistory.length > 0 && ( + +

近 {sortedHistory.length} 个月工资趋势

+
+ {sortedHistory.map((p: any) => ( +
+ {p.month.slice(5)}月 +
+
+ {Number(p.totalPay) / maxPay > 0.4 && ( + ¥{fmt(Number(p.totalPay))} + )}
- ¥{fmt(Number(p.totalPay))}
- ))} + {Number(p.totalPay) / maxPay <= 0.4 && ( + ¥{fmt(Number(p.totalPay))} + )} +
+ ))} +
+
+ )} + + {/* 工资条卡片 */} + {isLoading ? ( + +
+
+
+
+
+
+ + ) : !data ? ( + + + + ) : ( + <> + {/* 应发合计大卡片 */} + +
+ + 应发合计 +
+
¥{fmt(Number(data.totalPay))}
+
{month.replace('-', '年')}月
+
+ + {/* 工资明细 */} + +

工资明细

+
+ + + +
- )} - - {isLoading ? ( -
加载中...
- ) : !data ? ( - - ) : ( -
-
- 基本工资 - ¥{fmt(Number(data.baseSalary))} -
- {data.overtimePay > 0 && ( -
-
- 加班费 - ¥{fmt(Number(data.overtimePay))} -
+ {/* 确认状态 */} + + {data.confirmedAt ? ( +
+
+
- )} - {data.allowance > 0 && ( -
- 津贴 - ¥{fmt(Number(data.allowance))} -
- )} - {data.deduction > 0 && ( -
- 扣款 - -¥{fmt(Number(data.deduction))} -
- )} -
-
- 应发合计 - ¥{fmt(Number(data.totalPay))} +
+
已确认查收
+
{new Date(data.confirmedAt).toLocaleString()}
- - {data.confirmedAt ? ( -
- 已确认({new Date(data.confirmedAt).toLocaleString()}) -
- ) : ( - - )} -
- )} - -
+ ) : ( + + )} +
+ + )}
) } diff --git a/frontend/src/pages/portal/PortalLogin.tsx b/frontend/src/pages/portal/PortalLogin.tsx index 44c144c..c203a16 100644 --- a/frontend/src/pages/portal/PortalLogin.tsx +++ b/frontend/src/pages/portal/PortalLogin.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Building2 } from 'lucide-react' +import Logo from '../../components/ui/Logo' import api from '../../lib/api' import { Input, Label } from '../../components/ui/Input' import Button from '../../components/ui/Button' @@ -60,11 +60,11 @@ export default function PortalLogin() { } return ( -
+
-
- - 企业用工专家 — 员工端 +
+ + 企业用工专家 — 员工端
@@ -82,32 +82,32 @@ export default function PortalLogin() { {error &&
{error}
} {mode === 'password' ? ( -
+
- setPhone(e.target.value)} maxLength={11} /> + setPhone(e.target.value)} maxLength={11} className="h-11" />
- setPassword(e.target.value)} /> + setPassword(e.target.value)} className="h-11" />
-
) : ( -
+
- setPhone(e.target.value)} maxLength={11} /> + setPhone(e.target.value)} maxLength={11} className="h-11" />
- setCode(e.target.value)} maxLength={6} /> + setCode(e.target.value)} maxLength={6} className="h-11" />
-
@@ -117,7 +117,7 @@ export default function PortalLogin() { 验证码:{displayedCode}(开发阶段直接显示,生产环境将发送短信)
)} -