11
Some checks failed
Node CI / build (14.x, macOS-latest) (push) Has been cancelled
Node CI / build (14.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (14.x, windows-latest) (push) Has been cancelled
Node CI / build (16.x, macOS-latest) (push) Has been cancelled
Node CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node CI / build (16.x, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
coverage CI / build (push) Has been cancelled
Node pnpm CI / build (16.x, macOS-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, ubuntu-latest) (push) Has been cancelled
Node pnpm CI / build (16.x, windows-latest) (push) Has been cancelled

This commit is contained in:
francis-fh
2026-06-17 18:38:40 +08:00
parent e5a41feea9
commit 183a25f709
10 changed files with 1001 additions and 7 deletions

View File

@@ -0,0 +1,353 @@
@import '../theme.less';
.live-recruitment-page {
min-height: 100vh;
background: @jp-bg-page;
}
// ===================== Banner =====================
.live-banner {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 40%, #0f3460 100%);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -60%;
right: -15%;
width: 600px;
height: 600px;
border-radius: 50%;
background: radial-gradient(circle, rgba(24, 144, 255, 0.15) 0%, transparent 70%);
}
&::after {
content: '';
position: absolute;
bottom: -40%;
left: -10%;
width: 400px;
height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(102, 126, 234, 0.12) 0%, transparent 70%);
}
.live-banner-inner {
.jp-page-container();
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 24px;
padding-top: 36px;
padding-bottom: 36px;
}
.banner-text {
.banner-title {
color: #fff;
margin: 0 0 8px;
font-size: 30px;
font-weight: 700;
letter-spacing: 2px;
.banner-icon {
margin-right: 12px;
font-size: 32px;
color: #69b1ff;
}
}
.banner-subtitle {
color: rgba(255, 255, 255, 0.65);
font-size: 15px;
letter-spacing: 1px;
}
}
.banner-search {
width: 480px;
max-width: 100%;
.search-row {
display: flex;
align-items: stretch;
gap: 0;
height: 40px;
}
.search-type-select {
width: 90px;
flex-shrink: 0;
height: 40px;
.ant-select-selector {
height: 40px !important;
line-height: 38px !important;
border-radius: 24px 0 0 24px !important;
border: 1px solid rgba(255, 255, 255, 0.2);
border-right: none;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 14px;
.ant-select-selection-item {
line-height: 38px;
color: rgba(255, 255, 255, 0.85);
}
.ant-select-selection-search-input {
height: 40px;
}
}
.ant-select-arrow {
color: rgba(255, 255, 255, 0.5);
}
}
.search-input {
height: 40px;
border-radius: 0 24px 24px 0;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 14px;
.ant-input {
background: transparent;
color: #fff;
&::placeholder {
color: rgba(255, 255, 255, 0.45);
}
}
.ant-input-prefix {
color: rgba(255, 255, 255, 0.45);
margin-right: 8px;
}
.search-btn {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(24, 144, 255, 0.85);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #fff;
transition: all 0.3s;
&:hover {
background: @jp-primary;
transform: scale(1.05);
}
}
&:hover,
&:focus-within {
border-color: @jp-primary;
background: rgba(255, 255, 255, 0.12);
}
}
}
}
// ===================== Stats Bar =====================
.live-stats-bar {
background: @jp-bg-card;
border-bottom: 1px solid @jp-border;
.stats-inner {
.jp-page-container();
padding-top: 12px;
padding-bottom: 12px;
display: flex;
align-items: center;
gap: 24px;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
.stat-icon {
color: #ff4d4f;
font-size: 16px;
&.pulse {
animation: livePulse 1.5s ease-in-out infinite;
}
}
.stat-label {
color: @jp-text-secondary;
}
.stat-value {
color: @jp-primary;
font-weight: 600;
margin-left: 2px;
}
}
}
// ===================== Content =====================
.live-content {
padding: 28px 20px 48px;
.live-container {
.jp-page-container();
}
}
// ===================== Card =====================
.live-card {
border-radius: @jp-radius-lg !important;
overflow: hidden;
border: 1px solid @jp-border !important;
box-shadow: @jp-shadow-card;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transform: translateY(-6px);
box-shadow: 0 12px 32px rgba(24, 144, 255, 0.18);
border-color: @jp-primary-border !important;
.live-card-cover {
.cover-play-icon {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.live-card-action {
.action-text {
color: @jp-primary;
gap: 8px;
}
}
}
}
// Card cover (top decorated area)
.live-card-cover {
height: 120px;
position: relative;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 14px 16px;
.cover-play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0.6);
font-size: 48px;
color: rgba(255, 255, 255, 0.9);
opacity: 0;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
}
}
// Card body
.live-card-body {
padding: 18px 20px 16px;
.live-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: @jp-text-primary;
line-height: 1.5;
min-height: 44px;
}
.live-company {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid lighten(@jp-border, 3%);
.company-icon {
color: @jp-primary;
font-size: 14px;
flex-shrink: 0;
}
.company-name {
color: @jp-text-secondary;
font-size: 13px;
}
}
.live-time-range {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding: 8px 12px;
background: @jp-primary-light;
border-radius: 6px;
font-size: 15px;
font-weight: 600;
color: @jp-primary;
.anticon {
font-size: 16px;
color: @jp-primary;
}
.time-separator {
color: @jp-text-muted;
margin: 0 2px;
}
}
.live-card-action {
.action-text {
display: inline-flex;
align-items: center;
gap: 4px;
color: @jp-text-secondary;
font-size: 13px;
font-weight: 500;
transition: all 0.3s;
}
}
}
// ===================== Pagination =====================
.live-pagination {
display: flex;
justify-content: center;
margin-top: 36px;
}
// Empty state
.live-empty {
margin-top: 60px;
}
// ===================== Animations =====================
@keyframes livePulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.3);
}
}

View File

@@ -0,0 +1,247 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Card,
Col,
Row,
Select,
Spin,
Typography,
Input,
Pagination,
Empty,
message,
} from 'antd';
import {
PlayCircleOutlined,
SearchOutlined,
EnvironmentOutlined,
ClockCircleOutlined,
RightOutlined,
FireOutlined,
} from '@ant-design/icons';
import JobPortalHeader from '@/components/JobPortalHeader';
import { getLiveList, type LiveStreamItem } from '@/services/jobportal/live';
import './index.less';
const { Title, Text } = Typography;
/** 卡片渐变色盘,按 id 取模分配 */
const COVER_COLORS = [
'#667eea',
'#f093fb',
'#4facfe',
'#43e97b',
'#fa709a',
'#a18cd1',
'#30cfd0',
'#f6d365',
'#f5576c',
'#667eea',
'#764ba2',
'#f093fb',
];
const PAGE_SIZE = 9;
const getCoverColor = (id: number) => COVER_COLORS[id % COVER_COLORS.length];
const LiveRecruitmentPage: React.FC = () => {
const [list, setList] = useState<LiveStreamItem[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [pageNum, setPageNum] = useState(1);
const [searchValue, setSearchValue] = useState('');
const [keyword, setKeyword] = useState('');
const [searchType, setSearchType] = useState<'title' | 'companyName'>('title');
const fetchList = useCallback(async () => {
try {
setLoading(true);
const params: { title?: string; companyName?: string } = {};
if (keyword) {
params[searchType] = keyword;
}
const response = await getLiveList(params);
if (response?.code === 200) {
// 前端分页
const rows = response.rows || [];
const start = (pageNum - 1) * PAGE_SIZE;
setList(rows.slice(start, start + PAGE_SIZE));
setTotal(response.total || rows.length);
} else {
message.error(response?.msg || '获取直播列表失败');
}
} catch (error) {
console.error('获取直播列表失败:', error);
message.error('获取直播列表失败');
} finally {
setLoading(false);
}
}, [pageNum, keyword, searchType]);
useEffect(() => {
fetchList();
}, [fetchList]);
const handleSearch = () => {
setPageNum(1);
setKeyword(searchValue.trim());
};
const handleCardClick = (item: LiveStreamItem) => {
if (item.liveUrl) {
window.open(item.liveUrl, '_blank', 'noopener,noreferrer');
}
};
/** 格式化时间,只保留前 16 位 (yyyy-MM-dd HH:mm) */
const formatTime = (time?: string): string => {
if (!time) return '';
return time.length >= 16 ? time.substring(0, 16) : time;
};
return (
<div className="live-recruitment-page">
<JobPortalHeader showSearch={false} showHotJobs={false} />
{/* 顶部 Banner */}
<div className="live-banner">
<div className="live-banner-inner">
<div className="banner-text">
<Title level={2} className="banner-title">
<PlayCircleOutlined className="banner-icon" />
</Title>
<Text className="banner-subtitle">
线线
</Text>
</div>
<div className="banner-search">
<div className="search-row">
<Select
value={searchType}
onChange={(value) => setSearchType(value)}
size="large"
className="search-type-select"
options={[
{ value: 'title', label: '标题' },
{ value: 'companyName', label: '公司' },
]}
/>
<Input
placeholder={searchType === 'title' ? '搜索直播标题...' : '搜索公司名称...'}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onPressEnter={handleSearch}
allowClear
size="large"
className="search-input"
prefix={<SearchOutlined />}
suffix={
<div className="search-btn" onClick={handleSearch}>
<RightOutlined />
</div>
}
/>
</div>
</div>
</div>
</div>
{/* 统计栏 */}
<div className="live-stats-bar">
<div className="stats-inner">
<div className="stat-item">
<FireOutlined className="stat-icon pulse" />
<span className="stat-label"></span>
<span className="stat-value">{total} </span>
</div>
</div>
</div>
{/* 卡片列表 */}
<div className="live-content">
<div className="live-container">
<Spin spinning={loading}>
{list.length > 0 ? (
<>
<Row gutter={[20, 20]}>
{list.map((item) => (
<Col xs={24} sm={12} lg={8} key={item.id}>
<Card
className="live-card"
hoverable
bodyStyle={{ padding: 0 }}
onClick={() => handleCardClick(item)}
>
{/* 卡片顶部装饰 */}
<div
className="live-card-cover"
style={{
background: `linear-gradient(135deg, ${getCoverColor(item.id)}, ${getCoverColor(item.id)}dd)`,
}}
>
<PlayCircleOutlined className="cover-play-icon" />
</div>
{/* 卡片内容 */}
<div className="live-card-body">
<Title
level={5}
className="live-title"
ellipsis={{ rows: 2 }}
>
{item.title}
</Title>
<div className="live-company">
<EnvironmentOutlined className="company-icon" />
<Text ellipsis className="company-name">
{item.companyName}
</Text>
</div>
{(item.startTime || item.endTime) && (
<div className="live-time-range">
<ClockCircleOutlined />
<span>{formatTime(item.startTime) || '待定'}</span>
<span className="time-separator"></span>
<span>{formatTime(item.endTime) || '待定'}</span>
</div>
)}
<div className="live-card-action">
<span className="action-text">
<RightOutlined />
</span>
</div>
</div>
</Card>
</Col>
))}
</Row>
<div className="live-pagination">
<Pagination
current={pageNum}
pageSize={PAGE_SIZE}
total={total}
showSizeChanger={false}
showTotal={(t) => `${t} 场直播`}
onChange={(page) => setPageNum(page)}
/>
</div>
</>
) : (
!loading && (
<Empty
className="live-empty"
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={keyword ? '没有找到相关直播' : '暂无直播安排'}
/>
)
)}
</Spin>
</div>
</div>
</div>
);
};
export default LiveRecruitmentPage;

View File

@@ -169,6 +169,15 @@ const PersonalCenter: React.FC = () => {
hasArrow: !isRealNameVerified,
color: isRealNameVerified ? '#52c41a' : '#8c8c8c'
},
{
id: 2,
title: '在线面试',
icon: <SafetyCertificateOutlined />,
status: '',
hasArrow: true,
color: '#1890ff',
link: 'https://mc.repohub.cn/pro.html'
},
{
id: 4,
title: '通知与提醒',
@@ -180,8 +189,8 @@ const PersonalCenter: React.FC = () => {
], [isRealNameVerified]);
const handleServiceClick = (service: any) => {
if (service.hasArrow) {
// 预留跳转
if (service.link) {
window.open(service.link, '_blank');
}
};