<?php
|
/**
|
* WPCode 片段 4/4 — 后台管理 + Nginx 路由注册
|
* 名称建议:IM - 后台管理
|
* 类型:PHP Snippet
|
* 位置:Run everywhere
|
*
|
* 依赖:片段 1、2、3 必须先启用
|
*
|
* 包含:
|
* - 候选人列表页(搜索 / 状态筛选 / 分页)
|
* - 候选人详情页(完整表单信息 / 附件下载 / 面试记录 / 视频播放)
|
* - AJAX 发送面试邀请
|
* - 工具菜单:Nginx 配置向导 + 环境检测
|
*/
|
|
defined('ABSPATH') || exit;
|
|
/* ============================================================
|
注册菜单
|
============================================================ */
|
add_action('admin_menu', function () {
|
add_menu_page('Candidate Management', 'Candidate Management', 'edit_others_posts', 'im-candidates', 'im_admin_list', 'dashicons-groups', 30);
|
add_submenu_page('im-candidates', 'Candidate List', 'Candidate List', 'edit_others_posts', 'im-candidates', 'im_admin_list');
|
add_submenu_page('im-candidates', 'Candidate Details', '', 'edit_others_posts', 'im-candidate-detail', 'im_admin_detail');
|
add_submenu_page('tools.php', 'IM Server Setup', 'IM Server Setup', 'manage_options', 'im-server-setup', 'im_server_setup');
|
});
|
|
/* ============================================================
|
后台样式 + JS(只在管理页面加载)
|
============================================================ */
|
add_action('admin_head', function () {
|
$screen = get_current_screen();
|
if (!$screen)
|
return;
|
$pages = ['toplevel_page_im-candidates', 'candidates_page_im-candidate-detail'];
|
if (!in_array($screen->id, $pages, true) && strpos($screen->id, 'im-candidates') === false && strpos($screen->id, 'im-candidate-detail') === false)
|
return;
|
?>
|
<style>
|
/* 基础列表样式保留 */
|
.im-a {
|
max-width: 1200px
|
}
|
|
.im-a .widefat td {
|
vertical-align: middle;
|
padding: 11px 10px
|
}
|
|
.im-badge {
|
display: inline-block;
|
padding: 3px 10px;
|
border-radius: 999px;
|
font-size: 12px;
|
font-weight: 600;
|
border: 1px solid;
|
white-space: nowrap
|
}
|
|
.im-tag {
|
display: inline-block;
|
background: #eff6ff;
|
color: #1e40af;
|
border: 1px solid #bfdbfe;
|
border-radius: 6px;
|
padding: 4px 10px;
|
font-size: 13px;
|
margin: 3px 4px 3px 0;
|
font-weight: 500
|
}
|
|
.im-muted {
|
color: #9ca3af;
|
font-style: italic
|
}
|
|
.im-sub-cnt {
|
font-size: 12px;
|
color: #6b7280;
|
display: block;
|
margin-bottom: 3px
|
}
|
|
.im-empty {
|
text-align: center;
|
color: #9ca3af;
|
padding: 36px !important;
|
font-size: 15px
|
}
|
|
/* 高级详情页样式 */
|
.im-detail-wrap {
|
margin-top: 20px;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
background: #f3f4f6;
|
padding: 24px;
|
border-radius: 12px;
|
box-sizing: border-box
|
}
|
|
.im-detail-wrap * {
|
box-sizing: border-box
|
}
|
|
.im-back-link {
|
display: inline-flex;
|
align-items: center;
|
gap: 6px;
|
color: #4b5563;
|
text-decoration: none;
|
font-weight: 600;
|
font-size: 14px;
|
margin-bottom: 24px;
|
transition: color 0.2s
|
}
|
|
.im-back-link:hover {
|
color: #009dff
|
}
|
|
.im-hero-card {
|
background: #fff;
|
border-radius: 16px;
|
padding: 32px;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
|
margin-bottom: 24px;
|
position: relative;
|
overflow: hidden
|
}
|
|
.im-hero-card::before {
|
content: "";
|
position: absolute;
|
top: 0;
|
bottom: 0;
|
left: 0;
|
width: 6px;
|
background: linear-gradient(180deg, #009dff, #53eee0)
|
}
|
|
.im-hero-profile {
|
display: flex;
|
align-items: center;
|
gap: 24px
|
}
|
|
.im-avatar {
|
width: 80px;
|
height: 80px;
|
border-radius: 50%;
|
background: linear-gradient(135deg, #e0f2fe, #bae6fd);
|
color: #0369a1;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 28px;
|
font-weight: 700;
|
flex-shrink: 0
|
}
|
|
.im-hero-info h1 {
|
margin: 0 0 8px;
|
font-size: 26px;
|
font-weight: 700;
|
color: #111827;
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
padding: 0
|
}
|
|
.im-hero-meta {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 16px;
|
color: #4b5563;
|
font-size: 14px
|
}
|
|
.im-hero-meta-item {
|
display: flex;
|
align-items: center;
|
gap: 6px
|
}
|
|
.im-hero-meta-item svg {
|
color: #9ca3af
|
}
|
|
.im-main-grid {
|
display: grid;
|
grid-template-columns: 1fr 400px;
|
gap: 24px
|
}
|
|
@media(max-width:1100px) {
|
.im-main-grid {
|
grid-template-columns: 1fr
|
}
|
}
|
|
.im-card-modern {
|
background: #fff;
|
border-radius: 16px;
|
box-shadow: 0 3px 15px rgba(0, 0, 0, 0.03);
|
padding: 24px;
|
margin-bottom: 24px
|
}
|
|
.im-card-title {
|
font-size: 18px;
|
font-weight: 700;
|
color: #111827;
|
margin: 0 0 20px;
|
display: flex;
|
align-items: center;
|
gap: 10px
|
}
|
|
.im-card-title svg {
|
color: #009dff
|
}
|
|
.im-info-grid {
|
display: grid;
|
grid-template-columns: 1fr 1fr;
|
gap: 20px
|
}
|
|
.im-info-item {
|
display: flex;
|
flex-direction: column;
|
gap: 6px
|
}
|
|
.im-info-label {
|
font-size: 13px;
|
color: #6b7280;
|
font-weight: 600;
|
text-transform: uppercase;
|
letter-spacing: 0.5px
|
}
|
|
.im-info-val {
|
font-size: 15px;
|
color: #111827;
|
font-weight: 500;
|
line-height: 1.5
|
}
|
|
.im-info-val.full-width {
|
grid-column: 1/-1
|
}
|
|
.im-timeline {
|
position: relative;
|
padding-left: 24px;
|
border-left: 2px solid #e5e7eb;
|
margin-top: 12px
|
}
|
|
.im-timeline-item {
|
position: relative;
|
margin-bottom: 24px
|
}
|
|
.im-timeline-item:last-child {
|
margin-bottom: 0
|
}
|
|
.im-timeline-icon {
|
position: absolute;
|
left: -31px;
|
top: 0;
|
width: 14px;
|
height: 14px;
|
background: #fff;
|
border: 3px solid #009dff;
|
border-radius: 50%
|
}
|
|
.im-timeline-date {
|
font-size: 13px;
|
color: #009dff;
|
font-weight: 700;
|
margin-bottom: 4px
|
}
|
|
.im-timeline-title {
|
font-size: 16px;
|
font-weight: 700;
|
color: #111827;
|
margin: 0 0 4px
|
}
|
|
.im-timeline-sub {
|
font-size: 14px;
|
color: #4b5563
|
}
|
|
.im-doc-item {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
padding: 12px 16px;
|
background: #f9fafb;
|
border: 1px solid #f3f4f6;
|
border-radius: 10px;
|
margin-bottom: 12px;
|
transition: all 0.2s
|
}
|
|
.im-doc-item:hover {
|
border-color: #bfdbfe;
|
background: #eff6ff
|
}
|
|
.im-doc-info {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
font-size: 14px;
|
color: #111827;
|
font-weight: 500;
|
overflow: hidden
|
}
|
|
.im-doc-info svg {
|
color: #ef4444;
|
flex-shrink: 0
|
}
|
|
.im-doc-name {
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis
|
}
|
|
.im-doc-actions {
|
display: flex;
|
align-items: center;
|
gap: 8px
|
}
|
|
.im-doc-btn {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
width: 32px;
|
height: 32px;
|
border-radius: 6px;
|
background: #fff;
|
color: #4b5563;
|
border: 1px solid #d1d5db;
|
text-decoration: none;
|
transition: all 0.2s
|
}
|
|
.im-doc-btn:hover {
|
background: #f3f4f6;
|
color: #111827
|
}
|
|
.im-token-box {
|
background: #f8fafc;
|
border: 1px solid #e2e8f0;
|
border-radius: 12px;
|
padding: 20px;
|
margin-bottom: 16px;
|
position: relative
|
}
|
|
.im-token-status {
|
position: absolute;
|
top: 20px;
|
right: 20px
|
}
|
|
.im-token-date {
|
font-size: 14px;
|
color: #374151;
|
font-weight: 600;
|
margin-bottom: 4px
|
}
|
|
.im-token-exp {
|
font-size: 13px;
|
color: #64748b;
|
margin-bottom: 16px
|
}
|
|
.im-video-player {
|
width: 100%;
|
border-radius: 8px;
|
background: #0f172a;
|
max-height: 240px;
|
margin-bottom: 12px
|
}
|
|
.im-video-footer {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
font-size: 13px;
|
color: #4b5563
|
}
|
|
.im-video-dl {
|
display: inline-flex;
|
align-items: center;
|
gap: 4px;
|
color: #009dff;
|
text-decoration: none;
|
font-weight: 600
|
}
|
|
.im-video-dl:hover {
|
text-decoration: underline
|
}
|
|
.im-action-box {
|
background: linear-gradient(135deg, #f0f9ff, #e0f2fe);
|
border: 1px solid #bae6fd;
|
border-radius: 16px;
|
padding: 24px;
|
text-align: center;
|
margin-bottom: 24px
|
}
|
|
.im-action-box h3 {
|
margin: 0 0 12px;
|
font-size: 18px;
|
color: #0369a1;
|
font-weight: 700
|
}
|
|
.im-action-box p {
|
color: #0284c7;
|
font-size: 14px;
|
margin: 0 0 20px
|
}
|
|
.im-btn-primary {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
gap: 8px;
|
background: linear-gradient(135deg, #009dff, #3de1fe);
|
color: #fff;
|
border: none;
|
padding: 12px 24px;
|
border-radius: 8px;
|
font-size: 15px;
|
font-weight: 700;
|
cursor: pointer;
|
transition: all 0.3s;
|
box-shadow: 0 4px 12px rgba(0, 157, 255, 0.25);
|
width: 100%;
|
margin-bottom: 12px
|
}
|
|
.im-btn-primary:hover {
|
transform: translateY(-1px);
|
box-shadow: 0 6px 16px rgba(0, 157, 255, 0.35);
|
color: #fff
|
}
|
|
.im-btn-danger {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
gap: 8px;
|
background: linear-gradient(135deg, #ef4444, #f87171);
|
color: #fff;
|
border: none;
|
padding: 12px 24px;
|
border-radius: 8px;
|
font-size: 15px;
|
font-weight: 700;
|
cursor: pointer;
|
transition: all 0.3s;
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.25);
|
width: 100%
|
}
|
|
.im-btn-danger:hover {
|
transform: translateY(-1px);
|
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.35);
|
color: #fff
|
}
|
|
.im-btn-success {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
gap: 8px;
|
background: linear-gradient(135deg, #10b981, #34d399);
|
color: #fff;
|
border: none;
|
padding: 12px 24px;
|
border-radius: 8px;
|
font-size: 15px;
|
font-weight: 700;
|
cursor: pointer;
|
transition: all 0.3s;
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.25);
|
width: 100%
|
}
|
|
.im-btn-success:hover {
|
transform: translateY(-1px);
|
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.35);
|
color: #fff
|
}
|
|
.im-modal-bg {
|
position: fixed;
|
inset: 0;
|
background: rgba(17, 24, 39, 0.7);
|
backdrop-filter: blur(4px);
|
z-index: 99999;
|
display: flex;
|
align-items: center;
|
justify-content: center
|
}
|
|
.im-modal {
|
background: #fff;
|
border-radius: 16px;
|
padding: 32px;
|
max-width: 440px;
|
width: 90%;
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25)
|
}
|
|
.im-modal h3 {
|
margin: 0 0 12px;
|
font-size: 20px;
|
font-weight: 700;
|
color: #111827
|
}
|
|
.im-modal p {
|
color: #4b5563;
|
margin: 0 0 24px;
|
line-height: 1.6
|
}
|
|
.im-modal textarea {
|
width: 100%;
|
padding: 12px;
|
border: 1px solid #d1d5db;
|
border-radius: 8px;
|
margin-bottom: 20px;
|
min-height: 80px;
|
font-family: inherit
|
}
|
|
.im-modal-btns {
|
display: flex;
|
gap: 12px;
|
justify-content: flex-end
|
}
|
|
.im-btn-cancel {
|
background: #f3f4f6;
|
color: #374151;
|
border: none;
|
padding: 10px 20px;
|
border-radius: 8px;
|
font-weight: 600;
|
cursor: pointer
|
}
|
|
.im-btn-cancel:hover {
|
background: #e5e7eb
|
}
|
|
.im-btn-confirm {
|
background: #009dff;
|
color: #fff;
|
border: none;
|
padding: 10px 20px;
|
border-radius: 8px;
|
font-weight: 600;
|
cursor: pointer
|
}
|
|
.im-btn-confirm:hover {
|
background: #0284c7
|
}
|
|
.im-reject-reason {
|
background: #fef2f2;
|
border: 1px solid #fecaca;
|
color: #991b1b;
|
padding: 16px;
|
border-radius: 8px;
|
margin-bottom: 24px;
|
font-size: 14px
|
}
|
</style>
|
<script>
|
jQuery(function ($) {
|
var cid = null, actionType = '';
|
$(document).on('click', '.im-action-btn', function () {
|
cid = $(this).data('id');
|
actionType = $(this).data('action');
|
var name = $(this).data('name');
|
var title = '', body = '', showTextarea = false;
|
|
if (actionType === 'invite') {
|
title = 'Confirm Interview Invitation';
|
body = 'An interview invitation will be sent to <strong>' + name + '</strong>.<br>The email includes a dedicated interview link valid for <strong>24 hours</strong>. Proceed?';
|
} else if (actionType === 'reject') {
|
title = 'Reject Candidate';
|
body = 'Are you sure you want to reject <strong>' + name + '</strong>\'s application?<br>A rejection email will be sent.';
|
} else if (actionType === 'hire') {
|
title = 'Hire Candidate';
|
body = 'Are you sure you want to hire <strong>' + name + '</strong>?<br>An offer email and training link will be sent.';
|
} else if (actionType === 'resend_joinus') {
|
title = 'Resend Email';
|
body = 'A follow-up email will be sent again to <strong>' + name + '</strong> with the "Complete Detailed Form" link.';
|
} else if (actionType === 'resend_training') {
|
title = 'Resend Training Link';
|
body = 'A training link email will be resent to <strong>' + name + '</strong>.';
|
} else if (actionType === 'resend_trained_email') {
|
title = 'Resend Account Email';
|
body = 'A Microsoft Teams temporary account email will be resent to <strong>' + name + '</strong>.';
|
} else if (actionType === 'delete') {
|
title = 'Delete Candidate';
|
body = 'Permanently delete all records of <strong>' + name + '</strong>?<br><span style="color:#ef4444">This action is irreversible and will also delete all uploaded files (attachments, interview videos, etc.).</span>';
|
}
|
|
$('#im-modal h3').text(title);
|
$('#im-modal-body').html(body);
|
if (showTextarea) {
|
$('#im-modal-textarea').show().val('');
|
} else {
|
$('#im-modal-textarea').hide();
|
}
|
$('#im-modal').fadeIn(150);
|
});
|
|
$(document).on('click', '.im-modal-cancel,.im-modal-bg', function (e) { if (e.target === this || $(this).hasClass('im-modal-cancel')) $('#im-modal').fadeOut(150); });
|
$(document).on('keydown', function (e) { if (e.key === 'Escape') $('#im-modal').fadeOut(150); });
|
|
$('#im-modal-ok').on('click', function (e) {
|
e.preventDefault();
|
if (!cid) return;
|
var reason = $('#im-modal-textarea').is(':visible') ? $.trim($('#im-modal-textarea').val()) : '';
|
|
var $b = $(this); $b.prop('disabled', true).text('Processing...');
|
|
var $modal = $('#im-modal');
|
var ajaxActUrl = $modal.data('ajaxurl') || '/wp-admin/admin-ajax.php';
|
var ajaxNonce = $modal.data('nonce') || '';
|
|
var ajaxAction = 'im_action_' + actionType;
|
$.ajax({
|
url: ajaxActUrl,
|
type: 'POST',
|
dataType: 'json',
|
data: { action: ajaxAction, nonce: ajaxNonce, candidate_id: cid, reason: reason },
|
success: function (r) {
|
$('#im-modal').fadeOut(150); $b.prop('disabled', false).text('Confirm');
|
var isSuccess = r && r.success;
|
var msg = r && r.data && r.data.message ? ((isSuccess ? '✅ ' : '❌ ') + r.data.message) : (isSuccess ? '✅ Action completed successfully' : '❌ Action failed, please try again');
|
var $n = $('<div class="notice ' + (isSuccess ? 'notice-success' : 'notice-error') + ' is-dismissible" style="margin-top:16px"><p>' + msg + '</p></div>');
|
if ($('.wp-heading-inline').length) {
|
$('.wp-heading-inline').after($n);
|
} else if ($('.im-detail-wrap').length) {
|
$('.im-detail-wrap').prepend($n);
|
}
|
var $res = $('#im-res-' + cid);
|
if ($res.length) $res.removeClass('ok err').addClass(isSuccess ? 'ok' : 'err').text(msg);
|
if (isSuccess) setTimeout(function () { location.reload(); }, 1000);
|
else setTimeout(function () { $n.fadeOut(400, function () { $(this).remove(); }); }, 5000);
|
},
|
error: function (xhr, status, error) {
|
$('#im-modal').fadeOut(150); $b.prop('disabled', false).text('Confirm');
|
var errMsg = 'Network is busy or server error occurred. Please try again.';
|
if (xhr.status === 403) errMsg = 'Request denied (403 Forbidden). This is usually caused by a security plugin (e.g., Wordfence/BtPanel WAF) blocking parameterized requests. Please whitelist the admin path or contact your administrator.';
|
alert(errMsg);
|
}
|
});
|
});
|
});
|
</script>
|
<?php
|
});
|
|
/* ============================================================
|
AJAX 处理
|
============================================================ */
|
add_action('wp_ajax_im_action_invite', function () {
|
check_ajax_referer('im_admin_nonce', 'nonce');
|
if (!current_user_can('edit_others_posts'))
|
wp_send_json_error(['message' => 'Insufficient permissions']);
|
$id = (int) ($_POST['candidate_id'] ?? 0);
|
if (!$id)
|
wp_send_json_error(['message' => 'Invalid parameters']);
|
|
$c = IM_Candidate::get($id);
|
if ($c && $c->status === 'screening') {
|
IM_Candidate::update_status($id, 'invited');
|
}
|
IM_Mailer::send_interview_invite($id)
|
? wp_send_json_success(['message' => 'Interview invitation email sent successfully!'])
|
: wp_send_json_error(['message' => 'Email sending failed. Please check mail configuration.']);
|
});
|
|
add_action('wp_ajax_im_action_reject', function () {
|
check_ajax_referer('im_admin_nonce', 'nonce');
|
if (!current_user_can('edit_others_posts'))
|
wp_send_json_error(['message' => 'Insufficient permissions']);
|
$id = (int) ($_POST['candidate_id'] ?? 0);
|
if (!$id)
|
wp_send_json_error(['message' => 'Invalid parameters']);
|
IM_Candidate::update_status($id, 'rejected');
|
IM_Mailer::send_reject($id)
|
? wp_send_json_success(['message' => 'Candidate rejected and email sent!'])
|
: wp_send_json_error(['message' => 'Status updated, but email sending failed.']);
|
});
|
|
add_action('wp_ajax_im_action_hire', function () {
|
check_ajax_referer('im_admin_nonce', 'nonce');
|
if (!current_user_can('edit_others_posts'))
|
wp_send_json_error(['message' => 'Insufficient permissions']);
|
$id = (int) ($_POST['candidate_id'] ?? 0);
|
if (!$id)
|
wp_send_json_error(['message' => 'Invalid parameters']);
|
IM_Mailer::send_hire($id)
|
? wp_send_json_success(['message' => 'Candidate hired and training link email sent!'])
|
: wp_send_json_error(['message' => 'Hiring failed. Please check mail configuration.']);
|
});
|
|
add_action('wp_ajax_im_action_resend_joinus', function () {
|
check_ajax_referer('im_admin_nonce', 'nonce');
|
if (!current_user_can('edit_others_posts'))
|
wp_send_json_error(['message' => 'Insufficient permissions']);
|
$id = (int) ($_POST['candidate_id'] ?? 0);
|
if (!$id)
|
wp_send_json_error(['message' => 'Invalid parameters']);
|
IM_Mailer::send_joinus_confirmation($id)
|
? wp_send_json_success(['message' => 'Form link email resent!'])
|
: wp_send_json_error(['message' => 'Email sending failed.']);
|
});
|
|
add_action('wp_ajax_im_action_resend_training', function () {
|
check_ajax_referer('im_admin_nonce', 'nonce');
|
if (!current_user_can('edit_others_posts'))
|
wp_send_json_error(['message' => 'Insufficient permissions']);
|
$id = (int) ($_POST['candidate_id'] ?? 0);
|
if (!$id)
|
wp_send_json_error(['message' => 'Invalid parameters']);
|
$c = IM_Candidate::get($id);
|
if (!$c || $c->status !== 'training')
|
wp_send_json_error(['message' => 'Candidate is not in training status']);
|
IM_Mailer::send_hire($id)
|
? wp_send_json_success(['message' => 'Training link email resent!'])
|
: wp_send_json_error(['message' => 'Email sending failed.']);
|
});
|
|
add_action('wp_ajax_im_action_resend_trained_email', function () {
|
check_ajax_referer('im_admin_nonce', 'nonce');
|
if (!current_user_can('edit_others_posts'))
|
wp_send_json_error(['message' => 'Insufficient permissions']);
|
$id = (int) ($_POST['candidate_id'] ?? 0);
|
if (!$id)
|
wp_send_json_error(['message' => 'Invalid parameters']);
|
$c = IM_Candidate::get($id);
|
if (!$c || $c->status !== 'trained')
|
wp_send_json_error(['message' => 'Candidate is not in training-completed status']);
|
IM_Mailer::send_training_complete($id)
|
? wp_send_json_success(['message' => 'Account email resent!'])
|
: wp_send_json_error(['message' => 'Email sending failed.']);
|
});
|
|
add_action('wp_ajax_im_action_delete', function () {
|
check_ajax_referer('im_admin_nonce', 'nonce');
|
if (!current_user_can('edit_others_posts'))
|
wp_send_json_error(['message' => 'Insufficient permissions']);
|
$id = (int) ($_POST['candidate_id'] ?? 0);
|
if (!$id)
|
wp_send_json_error(['message' => 'Invalid parameters']);
|
IM_Candidate::delete_with_files($id)
|
? wp_send_json_success(['message' => 'Candidate record and related files deleted!'])
|
: wp_send_json_error(['message' => 'Delete failed, please try again.']);
|
});
|
|
/* ============================================================
|
候选人列表页
|
============================================================ */
|
function im_admin_list()
|
{
|
$page = max(1, (int) ($_GET['paged'] ?? 1));
|
$status = sanitize_text_field($_GET['status'] ?? '');
|
$search = sanitize_text_field($_GET['s'] ?? '');
|
$per_page = 20;
|
$args = compact('page', 'status', 'search') + ['per_page' => $per_page];
|
$items = IM_Candidate::get_list($args);
|
$total = IM_Candidate::count($args);
|
$pages = (int) ceil($total / $per_page);
|
|
$cnt = [
|
'' => IM_Candidate::count(),
|
'applied' => IM_Candidate::count(['status' => 'applied']),
|
'screening' => IM_Candidate::count(['status' => 'screening']),
|
'invited' => IM_Candidate::count(['status' => 'invited']),
|
'rejected' => IM_Candidate::count(['status' => 'rejected']),
|
'completed' => IM_Candidate::count(['status' => 'completed']),
|
'hired' => IM_Candidate::count(['status' => 'hired']),
|
'training' => IM_Candidate::count(['status' => 'training']),
|
'trained' => IM_Candidate::count(['status' => 'trained']),
|
];
|
$tabs = [
|
'' => 'All',
|
'applied' => 'Applied',
|
'screening' => 'Pending Review',
|
'invited' => 'Invited',
|
'rejected' => 'Rejected',
|
'completed' => 'Completed',
|
'hired' => 'Hired',
|
'training' => 'Training In Progress',
|
'trained' => 'Training Completed'
|
];
|
|
$status_lbl = [
|
'applied' => 'Awaiting Detailed Form',
|
'screening' => 'Detailed Form Submitted',
|
'invited' => 'Invited',
|
'rejected' => 'Rejected',
|
'completed' => 'Completed',
|
'hired' => 'Hired',
|
'training' => 'Training In Progress',
|
'trained' => 'Training Completed'
|
];
|
|
$colors = [
|
'applied' => '#f59e0b',
|
'screening' => '#8b5cf6',
|
'invited' => '#3b82f6',
|
'rejected' => '#ef4444',
|
'completed' => '#10b981',
|
'hired' => '#059669',
|
'training' => '#f97316',
|
'trained' => '#06b6d4'
|
];
|
?>
|
<div class="wrap im-a">
|
<h1 class="wp-heading-inline">Candidate Management</h1>
|
<hr class="wp-header-end">
|
|
<ul class="subsubsub">
|
<?php foreach ($tabs as $key => $lbl):
|
$url = admin_url('admin.php?page=im-candidates' . ($key ? '&status=' . $key : ''));
|
?>
|
<li><a href="<?= esc_url($url) ?>" <?= $status === $key ? 'class="current"' : '' ?>>
|
<?= $lbl ?> <span class="count">(<?= $cnt[$key] ?>)</span>
|
</a><?= $key !== 'trained' ? ' |' : '' ?></li>
|
<?php endforeach; ?>
|
</ul>
|
|
<form method="get" style="margin:8px 0 16px">
|
<input type="hidden" name="page" value="im-candidates">
|
<?php if ($status): ?><input type="hidden" name="status" value="<?= esc_attr($status) ?>"><?php endif; ?>
|
<p class="search-box" style="margin:0">
|
<input type="search" name="s" value="<?= esc_attr($search) ?>" placeholder="Search by name or email..." style="width:240px">
|
<button type="submit" class="button">Search</button>
|
</p>
|
</form>
|
|
<table class="wp-list-table widefat fixed striped">
|
<thead>
|
<tr>
|
<th width="160">Name</th>
|
<th>Email</th>
|
<th width="140">Updated</th>
|
<th width="120">Status</th>
|
<th>Subjects / Details</th>
|
<th width="240">Actions</th>
|
</tr>
|
</thead>
|
<tbody>
|
<?php if ($items):
|
foreach ($items as $c):
|
$detail = admin_url('admin.php?page=im-candidate-detail&id=' . $c->id);
|
$subs = IM_Candidate::get_subjects($c);
|
$color = $colors[$c->status] ?? '#6b7280';
|
$clabel = $status_lbl[$c->status] ?? $c->status;
|
$name_disp = $c->status === 'applied' ? esc_html($c->last_name . ' ' . $c->first_name) : '<a href="' . esc_url($detail) . '">' . esc_html($c->last_name . ' ' . $c->first_name) . '</a>';
|
?>
|
<tr>
|
<td>
|
<strong><?= $name_disp ?></strong>
|
<?php if ($c->preferred_name): ?>
|
<span style="color:#9ca3af;font-size:12px">(<?= esc_html($c->preferred_name) ?>)</span>
|
<?php endif; ?>
|
</td>
|
<td><a href="mailto:<?= esc_attr($c->email) ?>"><?= esc_html($c->email) ?></a></td>
|
<td style="font-size:13px"><?= esc_html(date('Y-m-d H:i', strtotime($c->updated_at))) ?></td>
|
<td>
|
<span class="im-badge"
|
style="background:<?= $color ?>18;color:<?= $color ?>;border-color:<?= $color ?>44"><?= $clabel ?></span>
|
<?php if ($c->status === 'applied' && !empty($c->apply_opened_at)): ?>
|
<div style="font-size:12px;color:#059669;margin-top:4px">✓ Form opened</div>
|
<?php elseif ($c->status === 'applied' && empty($c->apply_opened_at)): ?>
|
<div style="font-size:12px;color:#9ca3af;margin-top:4px">○ Form not opened</div>
|
<?php elseif ($c->status === 'invited'): ?>
|
<?php $tk_list = IM_Token::get_by_candidate($c->id);
|
if (!empty($tk_list)):
|
$tk = $tk_list[0]; ?>
|
<?php if (!empty($tk->opened_at)): ?>
|
<div style="font-size:12px;color:#059669;margin-top:4px">✓ Interview questions viewed</div>
|
<?php else: ?>
|
<div style="font-size:12px;color:#9ca3af;margin-top:4px">○ Interview link not opened</div>
|
<?php endif; ?>
|
<?php endif; ?>
|
<?php elseif ($c->status === 'training'): ?>
|
<?php if (!empty($c->training_opened_at)): ?>
|
<div style="font-size:12px;color:#059669;margin-top:4px">✓ Training page opened</div>
|
<?php else: ?>
|
<div style="font-size:12px;color:#9ca3af;margin-top:4px">○ Training page not opened</div>
|
<?php endif; ?>
|
<?php elseif ($c->status === 'trained'): ?>
|
<?php if (!empty($c->training_completed_at)): ?>
|
<div style="font-size:12px;color:#059669;margin-top:4px">✓ <?= date('m/d H:i', strtotime($c->training_completed_at)) ?> Completed</div>
|
<?php endif; ?>
|
<?php endif; ?>
|
</td>
|
<td>
|
<?php if ($c->status === 'applied'): ?>
|
<span class="im-muted">Waiting for detailed form</span>
|
<?php elseif ($subs): ?>
|
<?php foreach (array_slice($subs, 0, 3) as $s): ?>
|
<span class="im-tag"><?= esc_html($s) ?></span>
|
<?php endforeach; ?>
|
<?php if (count($subs) > 3): ?><span
|
class="im-tag im-tag-more">+<?= count($subs) - 3 ?></span><?php endif; ?>
|
<?php else: ?><span class="im-muted">—</span><?php endif; ?>
|
</td>
|
<td>
|
<?php if ($c->status !== 'applied'): ?>
|
<a href="<?= esc_url($detail) ?>" class="button button-small">View Details</a>
|
<?php endif; ?>
|
|
<?php if ($c->status === 'applied'): ?>
|
<button class="button button-small im-action-btn" data-action="resend_joinus"
|
data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->last_name . ' ' . $c->first_name) ?>">Resend Email</button>
|
<?php elseif ($c->status === 'screening'): ?>
|
<button class="button button-small button-primary im-action-btn" data-action="invite"
|
data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->preferred_name ?: $c->first_name) ?>">Send Interview Invitation</button>
|
<button class="button button-small im-action-btn" data-action="reject" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->preferred_name ?: $c->first_name) ?>">Reject</button>
|
<?php elseif ($c->status === 'invited'): ?>
|
<button class="button button-small im-action-btn" data-action="invite" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->preferred_name ?: $c->first_name) ?>">Resend Invitation</button>
|
<button class="button button-small im-action-btn" data-action="reject" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->preferred_name ?: $c->first_name) ?>">Reject</button>
|
<?php elseif ($c->status === 'completed'): ?>
|
<button class="button button-small im-action-btn" style="border-color:#10b981;color:#10b981"
|
data-action="hire" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->preferred_name ?: $c->first_name) ?>">Hire</button>
|
<button class="button button-small im-action-btn" data-action="reject" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->preferred_name ?: $c->first_name) ?>">Reject</button>
|
<?php elseif ($c->status === 'training'): ?>
|
<button class="button button-small im-action-btn" data-action="resend_training"
|
data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->preferred_name ?: $c->first_name) ?>">Resend Training Link</button>
|
<?php elseif ($c->status === 'trained'): ?>
|
<button class="button button-small im-action-btn" data-action="resend_trained_email"
|
data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->preferred_name ?: $c->first_name) ?>">Resend Account Email</button>
|
<?php endif; ?>
|
|
<button class="button button-small im-action-btn" style="color:#ef4444;border-color:#fca5a5"
|
data-action="delete" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($c->last_name . ' ' . $c->first_name) ?>">Delete</button>
|
|
<?php if (defined('IM_DEBUG_LINKS') && IM_DEBUG_LINKS): ?>
|
<div
|
style="margin-top:12px;padding-top:8px;border-top:1px dashed #e5e7eb;font-size:12px;word-break:break-all;line-height:1.5">
|
<?php if (empty($c->apply_token_used) && $c->apply_token): ?>
|
<div style="color:#6b7280;margin-bottom:4px">Debug: Form Link <br><a
|
href="<?= esc_url(add_query_arg('im_apply_token', $c->apply_token, IM_APPLY_PAGE_URL)) ?>"
|
target="_blank"
|
style="color:#8b5cf6"><?= esc_html(add_query_arg('im_apply_token', $c->apply_token, IM_APPLY_PAGE_URL)) ?></a>
|
</div>
|
<?php endif; ?>
|
<?php if (in_array($c->status, ['invited', 'completed', 'hired', 'rejected', 'training', 'trained'])):
|
$latest_token = IM_Token::get_by_candidate($c->id);
|
if (!empty($latest_token)):
|
$tk2 = $latest_token[0]; ?>
|
<div style="color:#6b7280">Debug: Interview Link <br><a
|
href="<?= esc_url(add_query_arg('im_token', $tk2->token, IM_INTERVIEW_PAGE_URL)) ?>" target="_blank"
|
style="color:#3b82f6"><?= esc_html(add_query_arg('im_token', $tk2->token, IM_INTERVIEW_PAGE_URL)) ?></a>
|
</div>
|
<?php endif; endif; ?>
|
<?php if (in_array($c->status, ['training', 'trained']) && !empty($c->training_token)): ?>
|
<div style="color:#6b7280">Debug: Training Link <br><a
|
href="<?= esc_url(add_query_arg('im_training_token', $c->training_token, IM_TRAINING_PAGE_URL)) ?>" target="_blank"
|
style="color:#f97316"><?= esc_html(add_query_arg('im_training_token', $c->training_token, IM_TRAINING_PAGE_URL)) ?></a>
|
</div>
|
<?php endif; ?>
|
</div>
|
<?php endif; ?>
|
</td>
|
</tr>
|
<?php endforeach; else: ?>
|
<tr>
|
<td colspan="6" class="im-empty">No candidate data available</td>
|
</tr>
|
<?php endif; ?>
|
</tbody>
|
</table>
|
|
<?php if ($pages > 1):
|
$base = admin_url('admin.php?page=im-candidates' . ($status ? '&status=' . $status : '') . ($search ? '&s=' . urlencode($search) : ''));
|
?>
|
<div class="tablenav bottom">
|
<div style="display:flex;gap:4px;padding:8px 0">
|
<?php for ($i = 1; $i <= $pages; $i++): ?>
|
<a href="<?= $base ?>&paged=<?= $i ?>"
|
class="button <?= $i === $page ? 'button-primary' : '' ?>"><?= $i ?></a>
|
<?php endfor; ?>
|
</div>
|
</div>
|
<?php endif; ?>
|
</div>
|
<?php im_admin_modal(); ?>
|
<?php
|
}
|
|
/* ============================================================
|
候选人详情页
|
============================================================ */
|
function im_admin_detail()
|
{
|
$id = (int) ($_GET['id'] ?? 0);
|
$c = $id ? IM_Candidate::get($id) : null;
|
if (!$c) {
|
echo '<div class="wrap"><div class="notice notice-error"><p>Candidate does not exist.</p></div></div>';
|
return;
|
}
|
if ($c->status === 'applied') {
|
echo '<div class="wrap"><div class="notice notice-warning"><p>This candidate has not submitted the detailed application form yet, so details are unavailable.</p></div><a href="' . admin_url('admin.php?page=im-candidates') . '" class="button">Back to List</a></div>';
|
return;
|
}
|
|
$subjects = IM_Candidate::get_subjects($c);
|
$tokens = IM_Token::get_by_candidate($id);
|
$attachments = IM_Attachment::get_by_candidate($id);
|
$upload_dir = wp_upload_dir();
|
$status_lbl = [
|
'applied' => 'Awaiting Detailed Form',
|
'screening' => 'Detailed Form Submitted',
|
'invited' => 'Invited',
|
'rejected' => 'Rejected',
|
'completed' => 'Completed',
|
'hired' => 'Hired',
|
'training' => 'Training In Progress',
|
'trained' => 'Training Completed'
|
];
|
$status_colors = [
|
'applied' => ['bg' => '#fffbeb', 'text' => '#b45309', 'border' => '#fde68a'],
|
'screening' => ['bg' => '#f3e8ff', 'text' => '#6d28d9', 'border' => '#e9d5ff'],
|
'invited' => ['bg' => '#eff6ff', 'text' => '#1d4ed8', 'border' => '#bfdbfe'],
|
'rejected' => ['bg' => '#fef2f2', 'text' => '#b91c1c', 'border' => '#fecaca'],
|
'completed' => ['bg' => '#ecfdf5', 'text' => '#047857', 'border' => '#a7f3d0'],
|
'hired' => ['bg' => '#ecfccb', 'text' => '#166534', 'border' => '#d9f99d'],
|
'training' => ['bg' => '#fff7ed', 'text' => '#c2410c', 'border' => '#fed7aa'],
|
'trained' => ['bg' => '#ecfeff', 'text' => '#0e7490', 'border' => '#a5f3fc']
|
];
|
|
$full_name = esc_html($c->first_name . ' ' . $c->last_name);
|
$p_name = esc_html($c->preferred_name);
|
$initials = mb_substr(trim($c->first_name), 0, 1) . mb_substr(trim($c->last_name), 0, 1);
|
if (!$initials && $full_name)
|
$initials = mb_substr($full_name, 0, 1);
|
$s_col = $status_colors[$c->status];
|
?>
|
<div class="im-a im-detail-wrap">
|
<a href="<?= admin_url('admin.php?page=im-candidates') ?>" class="im-back-link">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
stroke-linecap="round" stroke-linejoin="round">
|
<path d="M19 12H5m7 7l-7-7 7-7" />
|
</svg>
|
Back to Candidate List
|
</a>
|
|
<!-- Hero Card -->
|
<div class="im-hero-card">
|
<div class="im-hero-profile">
|
<div class="im-avatar"><?= strtoupper($initials) ?></div>
|
<div class="im-hero-info">
|
<h1><?= $full_name ?>
|
<?= $p_name ? '<span style="color:#6b7280;font-size:18px;font-weight:500">(' . $p_name . ')</span>' : '' ?>
|
</h1>
|
<div class="im-hero-meta">
|
<span class="im-hero-meta-item">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
stroke-width="2">
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
<path d="M22 6l-10 7L2 6" />
|
</svg>
|
<a href="mailto:<?= esc_attr($c->email) ?>"
|
style="color:inherit;text-decoration:none"><?= esc_html($c->email) ?></a>
|
</span>
|
<?php if ($c->phone): ?>
|
<span class="im-hero-meta-item">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
stroke-width="2">
|
<path
|
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z" />
|
</svg>
|
<?= esc_html($c->phone) ?>
|
</span>
|
<?php endif; ?>
|
<?php if (!empty($c->country) || !empty($c->city)): ?>
|
<span class="im-hero-meta-item">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
stroke-width="2">
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
|
<circle cx="12" cy="10" r="3" />
|
</svg>
|
<?= esc_html(trim(($c->city ?? '') . (!empty($c->city) && !empty($c->country) ? ', ' : '') . ($c->country ?? ''))) ?>
|
</span>
|
<?php endif; ?>
|
</div>
|
</div>
|
</div>
|
<div>
|
<span class="im-badge"
|
style="background:<?= $s_col['bg'] ?>;color:<?= $s_col['text'] ?>;border-color:<?= $s_col['border'] ?>;padding:6px 16px;font-size:14px">
|
<?= $status_lbl[$c->status] ?>
|
</span>
|
</div>
|
</div>
|
|
<div class="im-main-grid">
|
<div class="im-col-main">
|
<!-- 详情卡片:教育背景 -->
|
<div class="im-card-modern">
|
<h2 class="im-card-title">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<path d="M22 10v6M2 10l10-5 10 5-10 5z" />
|
<path d="M6 12v5c3 3 9 3 12 0v-5" />
|
</svg>
|
Education
|
</h2>
|
<div class="im-timeline">
|
<div class="im-timeline-item">
|
<div class="im-timeline-icon"></div>
|
<?php $degree_map = ["Bachelor's" => 'Bachelor', "Master's" => 'Master', 'PhD' => 'PhD']; ?>
|
<div class="im-timeline-date"><?= esc_html($c->grad_year ?: 'Unknown year') ?> Graduated
|
(<?= esc_html($degree_map[$c->degree_level] ?? ($c->degree_level ?: 'Highest Degree')) ?>)</div>
|
<h3 class="im-timeline-title"><?= esc_html($c->university ?: '—') ?></h3>
|
<div class="im-timeline-sub">Major: <?= esc_html($c->major ?: '—') ?> |
|
GPA:<?= esc_html($c->gpa ?: '—') ?>
|
<?= $c->deans_list ? ' | 🏅 Dean\'s List' : '' ?>
|
</div>
|
</div>
|
<?php if ($c->ug_university): ?>
|
<div class="im-timeline-item">
|
<div class="im-timeline-icon" style="border-color:#d1d5db"></div>
|
<div class="im-timeline-date" style="color:#6b7280"><?= esc_html($c->ug_grad_year ?: 'Unknown year') ?>
|
Graduated (Bachelor Degree)</div>
|
<h3 class="im-timeline-title"><?= esc_html($c->ug_university) ?></h3>
|
<div class="im-timeline-sub">Major: <?= esc_html($c->ug_major ?: '—') ?></div>
|
</div>
|
<?php endif; ?>
|
<?php if (!empty($c->ms_university)): ?>
|
<div class="im-timeline-item">
|
<div class="im-timeline-icon" style="border-color:#8b5cf6"></div>
|
<div class="im-timeline-date" style="color:#8b5cf6"><?= esc_html($c->ms_grad_year ?: 'Unknown year') ?>
|
Graduated (Master Degree)</div>
|
<h3 class="im-timeline-title"><?= esc_html($c->ms_university) ?></h3>
|
<div class="im-timeline-sub">Major: <?= esc_html($c->ms_major ?: '—') ?></div>
|
</div>
|
<?php endif; ?>
|
<?php if ($c->ca_highschool): ?>
|
<div class="im-timeline-item">
|
<div class="im-timeline-icon" style="border-color:#f59e0b"></div>
|
<div class="im-timeline-date" style="color:#f59e0b">Canadian High School</div>
|
<h3 class="im-timeline-title"><?= esc_html($c->ca_highschool_name ?: '—') ?></h3>
|
<div class="im-timeline-sub">e.g. OSSD or BC curriculum</div>
|
</div>
|
<?php endif; ?>
|
</div>
|
</div>
|
|
<!-- 技能与经验 -->
|
<div class="im-card-modern">
|
<h2 class="im-card-title">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<path
|
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
</svg>
|
Skills & Experience
|
</h2>
|
<div class="im-info-grid">
|
<div class="im-info-item full-width">
|
<div class="im-info-label">Subjects (<?= count($subjects) ?>)</div>
|
<div class="im-info-val">
|
<?php if ($subjects):
|
foreach ($subjects as $s): ?>
|
<span class="im-tag"><?= esc_html($s) ?></span>
|
<?php endforeach; else: ?>
|
<span class="im-muted">No subjects selected</span>
|
<?php endif; ?>
|
</div>
|
</div>
|
<div class="im-info-item full-width">
|
<div class="im-info-label">English Fluency</div>
|
<div class="im-info-val"><?php
|
$fluency_map = ['Native' => 'Native', 'Fluent' => 'Fluent', 'Basic' => 'Basic'];
|
echo esc_html($fluency_map[$c->languages] ?? ($c->languages ?: '—'));
|
?></div>
|
</div>
|
<div class="im-info-item full-width">
|
<div class="im-info-label">Teaching Experience</div>
|
<div class="im-info-val"><?= nl2br(esc_html($c->teaching_exp ?: '—')) ?></div>
|
</div>
|
<?php if ($c->has_achievement): ?>
|
<div class="im-info-item full-width">
|
<div class="im-info-label">Personal Achievement (<?= esc_html($c->achievement_type) ?>)</div>
|
<div class="im-info-val"
|
style="background:#f9fafb;padding:12px 16px;border-radius:8px;border:1px solid #f3f4f6">
|
<?= nl2br(esc_html($c->achievement_desc ?: '—')) ?>
|
</div>
|
</div>
|
<?php endif; ?>
|
<?php if ($c->extra_notes): ?>
|
<div class="im-info-item full-width">
|
<div class="im-info-label">Additional Notes</div>
|
<div class="im-info-val"
|
style="background:#f9fafb;padding:12px 16px;border-radius:8px;border:1px solid #f3f4f6">
|
<?= nl2br(esc_html($c->extra_notes)) ?>
|
</div>
|
</div>
|
<?php endif; ?>
|
</div>
|
</div>
|
</div>
|
|
<!-- 右侧区域 -->
|
<div class="im-col-side">
|
<!-- 状态操作区 -->
|
<?php if ($c->status === 'screening'): ?>
|
<div class="im-action-box">
|
<h3>Review Actions</h3>
|
<p>Decide whether to invite this candidate for an interview</p>
|
<button class="im-btn-primary im-action-btn" data-action="invite" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($full_name) ?>">
|
Send Interview Invitation
|
</button>
|
<button class="im-btn-danger im-action-btn" data-action="reject" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($full_name) ?>">
|
Reject This Candidate
|
</button>
|
<div id="im-res-<?= $c->id ?>" style="margin-top:12px;font-size:14px;font-weight:600"></div>
|
</div>
|
<?php elseif ($c->status === 'invited'): ?>
|
<div class="im-action-box">
|
<h3>Awaiting Interview</h3>
|
<p>If the candidate did not receive the email, you can resend it</p>
|
<button class="im-btn-primary im-action-btn" data-action="invite" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($full_name) ?>">
|
Resend Invitation
|
</button>
|
<div id="im-res-<?= $c->id ?>" style="margin-top:12px;font-size:14px;font-weight:600"></div>
|
</div>
|
<?php elseif ($c->status === 'completed'): ?>
|
<div class="im-action-box" style="background:linear-gradient(135deg,#ecfdf5,#d1fae5);border-color:#a7f3d0">
|
<h3 style="color:#047857">Interview Review</h3>
|
<p style="color:#059669">Video has been submitted. Review it and hire if approved.</p>
|
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
<button class="im-btn-success im-action-btn" data-action="hire" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($full_name) ?>" style="flex:1">
|
Formally Hire Candidate
|
</button>
|
<button class="im-btn-danger im-action-btn" data-action="reject" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($full_name) ?>" style="flex:1">
|
Reject This Candidate
|
</button>
|
</div>
|
<div id="im-res-<?= $c->id ?>" style="margin-top:12px;font-size:14px;font-weight:600"></div>
|
</div>
|
<?php elseif ($c->status === 'training'): ?>
|
<div class="im-action-box" style="background:linear-gradient(135deg,#fff7ed,#ffedd5);border-color:#fed7aa">
|
<h3 style="color:#c2410c">Training In Progress</h3>
|
<p style="color:#ea580c">Candidate is completing training modules</p>
|
<?php if (!empty($c->training_opened_at)): ?>
|
<div style="font-size:13px;color:#059669;margin-bottom:12px;font-weight:600">✓ Opened training page at <?= date('Y/m/d H:i', strtotime($c->training_opened_at)) ?></div>
|
<?php else: ?>
|
<div style="font-size:13px;color:#9ca3af;margin-bottom:12px">○ Training page not opened yet</div>
|
<?php endif; ?>
|
<button class="im-btn-primary im-action-btn" data-action="resend_training" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($full_name) ?>">
|
Resend Training Link
|
</button>
|
<div id="im-res-<?= $c->id ?>" style="margin-top:12px;font-size:14px;font-weight:600"></div>
|
</div>
|
<?php elseif ($c->status === 'trained'): ?>
|
<div class="im-action-box" style="background:linear-gradient(135deg,#ecfeff,#cffafe);border-color:#a5f3fc">
|
<h3 style="color:#0e7490">Training Completed</h3>
|
<p style="color:#0891b2">Candidate has passed all training modules</p>
|
<?php if (!empty($c->training_completed_at)): ?>
|
<div style="font-size:13px;color:#059669;font-weight:600;margin-bottom:12px">✓ Completed training at <?= date('Y/m/d H:i', strtotime($c->training_completed_at)) ?></div>
|
<?php endif; ?>
|
<button class="im-btn-primary im-action-btn" data-action="resend_trained_email" data-id="<?= $c->id ?>"
|
data-name="<?= esc_attr($full_name) ?>">
|
Resend Account Email
|
</button>
|
<div id="im-res-<?= $c->id ?>" style="margin-top:12px;font-size:14px;font-weight:600"></div>
|
</div>
|
<?php endif; ?>
|
|
<!-- 附件列表 -->
|
<?php if ($attachments): ?>
|
<div class="im-card-modern">
|
<h2 class="im-card-title">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z" />
|
<path d="M13 2v7h7" />
|
</svg>
|
Attachments
|
</h2>
|
<?php
|
$grouped = [];
|
foreach ($attachments as $att)
|
$grouped[$att->file_type][] = $att;
|
$att_groups = ['transcript_files' => 'Transcripts', 'achievement_files' => 'Achievement Proofs', 'extra_files' => 'Additional Materials'];
|
foreach ($att_groups as $type => $label):
|
if (empty($grouped[$type]))
|
continue;
|
?>
|
<div style="margin-bottom:16px">
|
<div class="im-info-label" style="margin-bottom:8px"><?= $label ?></div>
|
<?php foreach ($grouped[$type] as $att):
|
$att_url = $upload_dir['baseurl'] . '/im-applications/' . $c->id . '/' . basename($att->file_path);
|
?>
|
<div class="im-doc-item">
|
<div class="im-doc-info">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
stroke-width="2">
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
<path d="M14 2v6h6" />
|
<path d="M16 13H8" />
|
<path d="M16 17H8" />
|
<path d="M10 9H8" />
|
</svg>
|
<div class="im-doc-name" title="<?= esc_attr($att->file_name) ?>">
|
<?= esc_html($att->file_name) ?>
|
</div>
|
</div>
|
<div class="im-doc-actions">
|
<a href="<?= esc_url($att_url) ?>" target="_blank" class="im-doc-btn" title="Preview in new tab">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
stroke-width="2">
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
<circle cx="12" cy="12" r="3" />
|
</svg>
|
</a>
|
<a href="<?= esc_url($att_url) ?>" download class="im-doc-btn" title="Download file">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
stroke-width="2">
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
</svg>
|
</a>
|
</div>
|
</div>
|
<?php endforeach; ?>
|
</div>
|
<?php endforeach; ?>
|
</div>
|
<?php endif; ?>
|
|
<!-- 面试记录 -->
|
<?php if (in_array($c->status, ['invited', 'completed', 'hired', 'rejected', 'training', 'trained'])): ?>
|
<div class="im-card-modern">
|
<h2 class="im-card-title">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<path d="M23 7l-7 5 7 5V7z" />
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
</svg>
|
Interview Records
|
</h2>
|
<?php if ($tokens):
|
foreach ($tokens as $tk):
|
$used = (bool) $tk->is_used;
|
$expired = strtotime($tk->expires_at) < time();
|
if ($used) {
|
$bs = '#dcfce7';
|
$bc = '#86efac';
|
$bt = '#166534';
|
$bl = 'Video Submitted';
|
} elseif ($expired) {
|
$bs = '#fee2e2';
|
$bc = '#fca5a5';
|
$bt = '#991b1b';
|
$bl = 'Link Expired';
|
} else {
|
$bs = '#dbeafe';
|
$bc = '#93c5fd';
|
$bt = '#1e40af';
|
$bl = 'Active';
|
}
|
?>
|
<div class="im-token-box">
|
<div class="im-token-status">
|
<span class="im-badge"
|
style="background:<?= $bs ?>;color:<?= $bt ?>;border-color:<?= $bc ?>"><?= $bl ?></span>
|
</div>
|
<div class="im-token-date"><?= date('Y/m/d H:i', strtotime($tk->created_at)) ?> Sent</div>
|
<div class="im-token-exp" style="margin-bottom:8px">Valid until
|
<?= date('m/d H:i', strtotime($tk->expires_at)) ?>
|
</div>
|
|
<?php if (!empty($tk->opened_at)): ?>
|
<div class="im-token-exp" style="color:#059669;font-weight:600">
|
✓ Viewed questions at <?= date('Y/m/d H:i', strtotime($tk->opened_at)) ?> <?= $used ? '' : '(countdown active)' ?>
|
</div>
|
<?php else: ?>
|
<div class="im-token-exp" style="color:#f59e0b">○ Candidate has not opened the link</div>
|
<?php endif; ?>
|
|
<?php if ($used && $tk->video_path && file_exists($tk->video_path)):
|
$vurl = $upload_dir['baseurl'] . '/interviews/' . $c->id . '/' . $tk->video_filename;
|
$fext = strtolower(pathinfo($tk->video_filename, PATHINFO_EXTENSION));
|
$archive_exts = ['zip', 'rar', '7z', 'gz', 'tar'];
|
$fsize = filesize($tk->video_path);
|
$fsize_str = $fsize > 1048576 ? round($fsize / 1048576, 1) . ' MB' : round($fsize / 1024, 1) . ' KB';
|
?>
|
<?php if (in_array($fext, $archive_exts, true)): ?>
|
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:20px 24px;display:flex;align-items:center;gap:16px;margin-top:8px">
|
<div style="width:48px;height:48px;background:#eef2ff;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="2"><path d="M21 8v13H3V3h12l6 5z"/><path d="M14 3v5h6"/></svg>
|
</div>
|
<div style="flex:1;min-width:0">
|
<div style="font-weight:600;color:#1e293b;font-size:14px;word-break:break-all"><?= esc_html($tk->video_filename) ?></div>
|
<div style="color:#64748b;font-size:13px;margin-top:2px"><?= strtoupper($fext) ?> archive · <?= $fsize_str ?></div>
|
</div>
|
<a href="<?= esc_url($vurl) ?>" download style="display:inline-flex;align-items:center;gap:6px;background:#6366f1;color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:600;text-decoration:none;white-space:nowrap">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>Download
|
</a>
|
</div>
|
<div style="margin-top:6px;font-size:12px;color:#94a3b8"><?= date('m/d H:i', strtotime($tk->submitted_at)) ?> submitted</div>
|
<?php else: ?>
|
<video controls class="im-video-player" preload="metadata">
|
<source src="<?= esc_url($vurl) ?>">
|
</video>
|
<div class="im-video-footer">
|
<span><?= date('m/d H:i', strtotime($tk->submitted_at)) ?> submitted</span>
|
<a href="<?= esc_url($vurl) ?>" download class="im-video-dl">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
stroke-width="2">
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
</svg>Download
|
</a>
|
</div>
|
<?php endif; ?>
|
<?php elseif ($used): ?>
|
<p class="im-muted" style="margin:0;font-size:13px">Video is offline or missing</p>
|
<?php endif; ?>
|
</div>
|
<?php endforeach; else: ?>
|
<div class="im-empty"
|
style="padding:24px!important;background:#f9fafb;border-radius:12px;border:1px dashed #e5e7eb">
|
Interview invitation has not been sent yet</div>
|
<?php endif; ?>
|
</div>
|
<?php endif; ?>
|
</div>
|
</div>
|
</div>
|
<?php im_admin_modal(); ?>
|
<?php
|
}
|
|
/* ============================================================
|
确认弹窗 HTML
|
============================================================ */
|
function im_admin_modal()
|
{ ?>
|
<div id="im-modal" style="display:none" class="im-modal-bg"
|
data-nonce="<?php echo esc_attr(wp_create_nonce('im_admin_nonce')); ?>"
|
data-ajaxurl="<?php echo esc_url(admin_url('admin-ajax.php')); ?>">
|
<div class="im-modal">
|
<h3>Action Confirmation</h3>
|
<p id="im-modal-body"></p>
|
<textarea id="im-modal-textarea" style="display:none" placeholder="Enter reason..."></textarea>
|
<div class="im-modal-btns">
|
<button class="button im-modal-cancel">Cancel</button>
|
<button class="button button-primary" id="im-modal-ok">Confirm</button>
|
</div>
|
</div>
|
</div>
|
<?php
|
}
|
|
/* ============================================================
|
Nginx 配置向导页(工具菜单)
|
============================================================ */
|
function im_server_setup()
|
{
|
$upload_dir = wp_upload_dir();
|
$home = rtrim(home_url('/'), '/');
|
|
$checks = [
|
['upload_max_filesize', ini_get('upload_max_filesize'), wp_convert_hr_to_bytes(ini_get('upload_max_filesize')) >= 400 * 1024 * 1024, 'Suggested: 500M'],
|
['post_max_size', ini_get('post_max_size'), wp_convert_hr_to_bytes(ini_get('post_max_size')) >= 400 * 1024 * 1024, 'Suggested: 512M'],
|
['max_execution_time', ini_get('max_execution_time') . 's', (int) ini_get('max_execution_time') === 0 || (int) ini_get('max_execution_time') >= 180, 'Suggested: 300'],
|
['memory_limit', ini_get('memory_limit'), true, '—'],
|
];
|
?>
|
<div class="wrap" style="max-width:860px">
|
<h1>Interview Manager — Server Setup Wizard</h1>
|
|
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:24px;margin-bottom:24px">
|
<h2 style="margin-top:0">1) Nginx Configuration (manual required)</h2>
|
<p>Add the following config inside the <code>server { }</code> block, then run <code>sudo nginx -t && sudo nginx -s reload</code>:</p>
|
<pre
|
style="background:#1e293b;color:#e2e8f0;padding:20px;border-radius:8px;font-size:13px;line-height:1.7;overflow-x:auto"># Video upload limits
|
client_max_body_size 512M;
|
client_body_timeout 300s;
|
send_timeout 300s;
|
|
# WordPress permalinks (skip if already set)
|
location / {
|
try_files $uri $uri/ /index.php?$args;
|
}
|
|
# Upload directory security
|
location ~* ^/wp-content/uploads/(interviews|im-applications)/ {
|
add_header X-Content-Type-Options nosniff;
|
}</pre>
|
</div>
|
|
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:24px;margin-bottom:24px">
|
<h2 style="margin-top:0">2) PHP Configuration (php.ini or .user.ini)</h2>
|
<pre style="background:#1e293b;color:#e2e8f0;padding:20px;border-radius:8px;font-size:13px;line-height:1.7">upload_max_filesize = 500M
|
post_max_size = 512M
|
max_execution_time = 300
|
max_input_time = 300
|
memory_limit = 256M</pre>
|
<p>Restart PHP-FPM after changes: <code>sudo systemctl restart php8.x-fpm</code></p>
|
</div>
|
|
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:24px;margin-bottom:24px">
|
<h2 style="margin-top:0">3) Refresh WordPress Permalinks</h2>
|
<p>Go to <strong>Dashboard → Settings → Permalinks</strong>, then click "Save Changes" directly (no settings change needed).</p>
|
<p><a href="<?= admin_url('options-permalink.php') ?>" class="button button-primary" target="_blank">Go to Permalink Settings
|
→</a></p>
|
</div>
|
|
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:24px;margin-bottom:24px">
|
<h2 style="margin-top:0">4) WordPress Page Setup</h2>
|
<p>Create the following pages in WordPress admin:</p>
|
<table class="widefat" style="max-width:600px">
|
<tr>
|
<th>Page</th>
|
<th>Shortcode</th>
|
<th>Suggested URL slug</th>
|
</tr>
|
<tr>
|
<td>Join Us Registration Page</td>
|
<td><code>[im_joinus_form]</code></td>
|
<td><code>/join-us/</code></td>
|
</tr>
|
<tr>
|
<td>Detailed Application Form Page</td>
|
<td><code>[im_apply_form]</code></td>
|
<td><code>/apply/</code></td>
|
</tr>
|
<tr>
|
<td>Interview Page</td>
|
<td><code>[im_interview]</code></td>
|
<td><code>/interview/</code></td>
|
</tr>
|
<tr>
|
<td>Training Page</td>
|
<td><code>[im_training]</code></td>
|
<td><code>/training/</code></td>
|
</tr>
|
</table>
|
<p style="margin-top:12px">After creating pages, update the constants at the top of snippet 1 with actual URLs:</p>
|
<code>define('IM_APPLY_PAGE_URL', home_url('/apply/'));</code><br>
|
<code>define('IM_INTERVIEW_PAGE_URL', home_url('/interview/'));</code><br>
|
<code>define('IM_TRAINING_PAGE_URL', home_url('/training/'));</code><br>
|
<code>define('IM_TRAINING_ACCOUNT', 'your_account@example.com');</code><br>
|
<code>define('IM_TRAINING_PASSWORD', 'your_password');</code>
|
</div>
|
|
<div style="background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:24px">
|
<h2 style="margin-top:0">5) Current Environment Check</h2>
|
<table class="widefat" style="max-width:640px">
|
<thead>
|
<tr>
|
<th>Configuration</th>
|
<th>Current Value</th>
|
<th>Status</th>
|
</tr>
|
</thead>
|
<tbody>
|
<?php foreach ($checks as [$name, $val, $ok, $tip]): ?>
|
<tr>
|
<td><code><?= $name ?></code></td>
|
<td><?= esc_html($val) ?></td>
|
<td><?= $ok ? '✅' : '⚠️ ' . $tip ?></td>
|
</tr>
|
<?php endforeach; ?>
|
<tr>
|
<td>Video Storage Directory</td>
|
<td style="font-size:12px"><?= esc_html($upload_dir['basedir'] . '/interviews/') ?></td>
|
<td><?= is_dir($upload_dir['basedir'] . '/interviews') ? (is_writable($upload_dir['basedir'] . '/interviews') ? '✅ Writable' : '❌ Not writable') : '(Auto-created on first upload)' ?>
|
</td>
|
</tr>
|
<tr>
|
<td>Application Files Directory</td>
|
<td style="font-size:12px"><?= esc_html($upload_dir['basedir'] . '/im-applications/') ?></td>
|
<td><?= is_dir($upload_dir['basedir'] . '/im-applications') ? (is_writable($upload_dir['basedir'] . '/im-applications') ? '✅ Writable' : '❌ Not writable') : '(Auto-created on first upload)' ?>
|
</td>
|
</tr>
|
<tr>
|
<td>Interview Page URL</td>
|
<td><a href="<?= esc_url(IM_INTERVIEW_PAGE_URL) ?>" target="_blank"
|
style="font-size:12px"><?= esc_html(IM_INTERVIEW_PAGE_URL) ?></a></td>
|
<td>—</td>
|
</tr>
|
<tr>
|
<td>Training Page URL</td>
|
<td><a href="<?= esc_url(IM_TRAINING_PAGE_URL) ?>" target="_blank"
|
style="font-size:12px"><?= esc_html(IM_TRAINING_PAGE_URL) ?></a></td>
|
<td>—</td>
|
</tr>
|
<tr>
|
<td>Training Temporary Account</td>
|
<td style="font-size:12px"><?= esc_html(IM_TRAINING_ACCOUNT) ?></td>
|
<td><?= IM_TRAINING_ACCOUNT !== 'your_account@example.com' ? '✅' : '⚠️ Configure real account' ?></td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
</div>
|
<?php
|
}
|