Files
prop-ai-hr/mobile-uni/src/pages/supervisor/assign/index.vue
T

812 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page assign-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回" title="返回" :disabled="assigning" @click="goBack">←</button>
<view class="subpage-heading">
<text class="page-title">指派专项</text>
<text class="page-subtitle">{{ selectedTargetLabel }}</text>
</view>
</view>
<view v-if="message && !accessDenied" class="status-message">{{ message }}</view>
<view v-if="accessDenied" class="panel supervisor-access-panel">
<text class="panel-title">无主管权限</text>
<text class="muted supervisor-access-copy">当前账号不是主管或项目经理,无法查看团队训练数据。</text>
<view class="supervisor-access-actions">
<button class="secondary-button" @click="goToEmployee">返回员工端</button>
<button class="primary-button" @click="switchAccount">切换账号</button>
</view>
</view>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后指派并查看最近专项。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="assign-tabs" role="tablist" aria-label="指派专项视图">
<button
class="assign-tab"
:class="{ active: assignView === 'create' }"
role="tab"
:aria-selected="assignView === 'create'"
@click="assignView = 'create'"
>
新建指派
</button>
<button
class="assign-tab"
:class="{ active: assignView === 'progress' }"
role="tab"
:aria-selected="assignView === 'progress'"
@click="assignView = 'progress'"
>
派发进度
</button>
</view>
<view v-if="assignView === 'create'" class="panel assignment-form">
<text class="field-label">训练对象</text>
<input
v-model="targetExtPartyId"
class="input field-input"
maxlength="32"
placeholder="输入手机号或外部ID"
@input="targetName = ''"
/>
<view v-if="memberSuggestions.length" class="target-suggestions">
<button
v-for="member in memberSuggestions"
:key="member.extPartyId"
class="target-suggestion"
@click="selectTarget(member)"
>
<text>{{ member.name || '未命名员工' }}</text>
<text>{{ maskPhone(member.extPartyId) }}</text>
</button>
</view>
<input
v-model="targetName"
class="input field-input"
maxlength="40"
placeholder="员工姓名(可选)"
/>
<text class="field-label section-label">训练场景</text>
<input
v-model="scenarioSearch"
class="input field-input"
maxlength="30"
placeholder="搜索训练场景"
/>
<view v-if="scenarioOptions.length" class="scenario-row">
<button
v-for="item in filteredScenarioOptions"
:key="item.id"
class="chip scenario-chip"
:class="{ active: assignmentScenarioId === item.id }"
:disabled="assigning"
@click="assignmentScenarioId = item.id"
>
{{ item.name }}
</button>
</view>
<text v-else class="muted block">暂无可派发的已启用场景</text>
<text class="field-label section-label">训练目标</text>
<input
v-model="assignmentGoal"
class="input field-input"
maxlength="200"
placeholder="例如:稳定完成投诉首轮承接与时限承诺"
/>
<text class="field-label section-label">任务要求</text>
<textarea
v-model="assignmentRequirements"
class="textarea requirements-input"
maxlength="500"
placeholder="填写练习次数、重点动作或合格标准"
/>
<text class="field-label section-label">截止时间</text>
<picker mode="date" :value="assignmentDueTime" @change="selectDueDate">
<view class="input due-date-field" :class="{ placeholder: !assignmentDueTime }">
{{ assignmentDueTime || '选择截止日期(可选)' }}
</view>
</picker>
<text class="field-label section-label">指派原因</text>
<textarea
v-model="assignmentReason"
class="textarea reason-input"
maxlength="180"
placeholder="填写本次专项训练原因"
/>
<button class="primary-button submit-button tabbar-safe-action" :disabled="assigning || historyLoading || !scenarioOptions.length" @click="assignPractice">
{{ assigning ? '指派中' : '确认指派' }}
</button>
</view>
<view v-if="assignView === 'progress'" class="panel history-panel">
<view class="section-head">
<text class="panel-title">最近专项</text>
<button
class="ghost-button refresh-button"
:disabled="historyLoading || assigning"
@click="loadAssignments()"
>
{{ historyLoading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="recentAssignments.length" class="assignment-list">
<view v-for="item in recentAssignments" :key="item.id" class="assignment-row">
<view class="assignment-copy">
<text class="assignment-title">{{ item.traineeName }} · {{ item.scenarioName }}</text>
<text class="assignment-desc">{{ assignmentSummary(item) }}</text>
<text v-if="item.taskGoal" class="assignment-goal">目标:{{ item.taskGoal }}</text>
<view class="assignment-progress-track" aria-hidden="true">
<view class="assignment-progress-value" :style="{ width: `${assignmentProgress(item)}%` }" />
</view>
</view>
<view class="assignment-status-copy">
<text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text>
<text v-if="item.dueTime" class="assignment-due" :class="{ overdue: isAssignmentOverdue(item) }">
{{ isAssignmentOverdue(item) ? '已逾期' : `截止 ${item.dueTime.slice(0, 10)}` }}
</text>
</view>
</view>
</view>
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view>
</view>
<view v-if="assignView === 'progress'" class="panel progress-panel">
<view class="section-head">
<text class="panel-title">派发进度与预警</text>
<text class="muted">{{ progressSummary }}</text>
</view>
<view class="progress-metrics">
<view><text>{{ progressCounts.pending }}</text><label>待训练</label></view>
<view><text>{{ progressCounts.completed }}</text><label>已完成</label></view>
<view><text>{{ progressCounts.retry }}</text><label>低分再练</label></view>
</view>
<view v-if="alerts.length" class="assignment-alert-list">
<view v-for="alert in alerts" :key="alert.type" class="assignment-alert" :class="alert.severity">
<text>{{ alert.title }}</text>
<text>{{ alert.desc }}</text>
</view>
</view>
<view v-else class="detail-empty">当前暂无团队预警</view>
</view>
</template>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="team" />
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { PracticeAlert, PracticeAssignment, PracticeTeamMember } from '@/types/api';
import { clearAuth, isLoggedIn, maskPhone, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { createPracticeAssignment, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import {
createAsyncGenerationGuard,
inferSupervisorScenarioId,
isSupervisorAccessDenied,
manualPracticeAssignments,
supervisorScenarioOptions,
type SupervisorScenarioOption,
type SupervisorScenarioId
} from '@/services/supervisor-view';
const basePath = '/pages/supervisor/assign/index';
const assignView = ref<'create' | 'progress'>('create');
const targetExtPartyId = ref('');
const targetName = ref('');
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
const scenarioSearch = ref('');
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
const assignmentGoal = ref('');
const assignmentRequirements = ref('');
const assignmentDueTime = ref('');
const assignmentReason = ref('');
const assignments = ref<PracticeAssignment[]>([]);
const members = ref<PracticeTeamMember[]>([]);
const alerts = ref<PracticeAlert[]>([]);
const loggedIn = ref(false);
const accessDenied = ref(false);
const assigning = ref(false);
const historyLoading = ref(false);
const message = ref('');
const historyRequests = createAsyncGenerationGuard();
const selectedTargetLabel = computed(() => {
if (!loggedIn.value) return '选择员工并配置训练任务';
const target = targetExtPartyId.value.trim();
if (!target) return '选择员工并配置训练任务';
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
});
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
const memberSuggestions = computed(() => {
const keyword = targetExtPartyId.value.trim().toLowerCase();
if (!keyword) return [];
return members.value
.filter((member) => {
const name = member.name?.trim().toLowerCase() || '';
return member.extPartyId.toLowerCase().includes(keyword) || name.includes(keyword);
})
.slice(0, 6);
});
const filteredScenarioOptions = computed(() => {
const keyword = scenarioSearch.value.trim().toLowerCase();
const options = keyword
? scenarioOptions.value.filter((item) => item.name.toLowerCase().includes(keyword))
: scenarioOptions.value;
const selected = scenarioOptions.value.find((item) => item.id === assignmentScenarioId.value);
const visible = selected && !options.some((item) => item.id === selected.id) ? [selected, ...options] : options;
return visible.slice(0, 6);
});
const progressCounts = computed(() => {
const items = recentAssignments.value;
return {
pending: items.filter((item) => item.status !== '已完成').length,
completed: items.filter((item) => item.status === '已完成').length,
retry: items.filter((item) => item.source === 'retry').length
};
});
const progressSummary = computed(() => `${progressCounts.value.pending} 项未完成`);
const queryText = (value: unknown) => {
if (typeof value !== 'string') return '';
try {
return decodeURIComponent(value).trim();
} catch {
return value.trim();
}
};
const isScenarioId = (value: string): value is SupervisorScenarioId =>
scenarioOptions.value.some((item) => item.id === value);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const resetDraft = () => {
targetExtPartyId.value = '';
targetName.value = '';
assignmentScenarioId.value = 'fee-parking';
scenarioSearch.value = '';
assignmentGoal.value = '';
assignmentRequirements.value = '';
assignmentDueTime.value = '';
assignmentReason.value = '';
};
const clearPrivateState = () => {
resetDraft();
assignView.value = 'create';
assignments.value = [];
members.value = [];
alerts.value = [];
historyLoading.value = false;
historyRequests.invalidate();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearPrivateState();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearPrivateState();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadAssignments = async (force = false) => {
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearPrivateState();
return;
}
if (!force && (historyLoading.value || assigning.value)) return;
const generation = historyRequests.begin();
historyLoading.value = true;
try {
const [snapshot, nextAlerts] = await Promise.all([getPracticeTeamSnapshot(), getPracticeAlerts()]);
if (historyRequests.isCurrent(generation)) {
accessDenied.value = false;
assignments.value = snapshot.assignments;
members.value = snapshot.members;
alerts.value = nextAlerts;
}
} catch (error) {
if (historyRequests.isCurrent(generation)) {
handleRequestError(error, '最近专项加载失败');
}
} finally {
if (historyRequests.isCurrent(generation)) {
historyLoading.value = false;
}
}
};
const selectTarget = (member: PracticeTeamMember) => {
targetExtPartyId.value = member.extPartyId;
targetName.value = member.name;
};
const selectDueDate = (event: { detail?: { value?: string } }) => {
assignmentDueTime.value = event.detail?.value || '';
};
const loadScenarioOptions = async () => {
try {
const rows = await getPracticeScenarios('生活顾问');
const nextOptions = rows
.filter((item) => item.enabled !== false && item.id && item.name)
.map((item) => ({ id: item.id, name: item.name }));
if (nextOptions.length) {
scenarioOptions.value = nextOptions;
if (!isScenarioId(assignmentScenarioId.value)) {
assignmentScenarioId.value = nextOptions[0].id;
}
return;
}
scenarioOptions.value = [];
assignmentScenarioId.value = '';
} catch {
scenarioOptions.value = [...supervisorScenarioOptions];
if (!isScenarioId(assignmentScenarioId.value)) {
assignmentScenarioId.value = supervisorScenarioOptions[0].id;
}
}
};
const loadPage = () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
clearPrivateState();
return;
}
void Promise.all([loadAssignments(), loadScenarioOptions()]);
};
const assignPractice = async () => {
if (assigning.value || historyLoading.value) return;
const extPartyId = targetExtPartyId.value.trim();
if (!extPartyId) {
message.value = '请填写员工手机号或外部ID';
return;
}
assigning.value = true;
message.value = '';
try {
const scenario = scenarioOptions.value.find((item) => item.id === assignmentScenarioId.value);
await createPracticeAssignment({
extPartyId,
traineeName: targetName.value.trim() || `手机用户${extPartyId.slice(-4)}`,
scenarioId: assignmentScenarioId.value,
scenarioName: scenario?.name,
reason: assignmentReason.value.trim() || '主管派发专项训练',
taskGoal: assignmentGoal.value.trim() || undefined,
taskRequirements: assignmentRequirements.value.trim() || undefined,
dueTime: assignmentDueTime.value || undefined
});
resetDraft();
uni.showToast({ title: '已指派', icon: 'none' });
await loadAssignments(true);
assignView.value = 'progress';
resetPageScroll();
} catch (error) {
handleRequestError(error, '指派失败');
} finally {
assigning.value = false;
}
};
const assignmentSourceLabel = (source: string) => {
if (source === 'retry') return '再练';
if (source === 'camp') return '训练营';
return '专项';
};
const assignmentSummary = (item: PracticeAssignment) => {
const reason = item.reason?.trim() || '主管派发专项训练';
return `${item.createTime} · ${item.status} · ${reason}`;
};
const assignmentProgress = (item: PracticeAssignment) => item.status === '已完成' ? 100 : 0;
const isAssignmentOverdue = (item: PracticeAssignment) => {
if (!item.dueTime || item.status === '已完成') return false;
const deadline = Date.parse(item.dueTime.replace(' ', 'T'));
return Number.isFinite(deadline) && deadline < Date.now();
};
const goLogin = () => {
rememberLoginRedirect(basePath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(basePath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (assigning.value) return;
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: '/pages/supervisor/index/index' });
return;
}
uni.navigateBack({
delta: 1,
fail: () => uni.redirectTo({ url: '/pages/supervisor/index/index' })
});
};
onLoad((query) => {
targetExtPartyId.value = queryText(query?.extPartyId);
targetName.value = queryText(query?.name);
assignmentReason.value = queryText(query?.reason);
const requestedScenario = queryText(query?.scenarioId);
assignmentScenarioId.value = requestedScenario || inferSupervisorScenarioId(assignmentReason.value);
});
onShow(() => {
resetPageScroll();
loadPage();
});
onBeforeUnmount(() => {
historyRequests.dispose();
});
</script>
<style scoped>
.assign-page {
padding-bottom: 112px;
background: linear-gradient(180deg, var(--employee-bg-soft) 0%, var(--employee-bg) 100%);
}
.login-panel,
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.assign-tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
margin-bottom: 14px;
padding: 4px;
border: 1px solid #dbe4ee;
border-radius: 14px;
background: #eef2f6;
}
.assign-tab {
min-height: 42px;
border: 0;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 800;
line-height: 42px;
}
.assign-tab::after {
border: 0;
}
.assign-tab.active {
background: #fff;
color: var(--employee-primary);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
.refresh-button {
min-width: 82px;
padding: 0 10px;
font-size: 13px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.field-label {
display: block;
color: #334155;
font-size: 13px;
font-weight: 800;
}
.section-label {
margin-top: 16px;
}
.field-input,
.reason-input,
.requirements-input,
.scenario-row,
.submit-button {
margin-top: 9px;
}
.field-input + .field-input {
margin-top: 10px;
}
.target-suggestions {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
margin-top: 6px;
border: 1px solid #dbe4ee;
border-radius: 12px;
background: #fff;
}
.target-suggestion {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
padding: 0 12px;
border: 0;
border-radius: 0;
background: #fff;
color: #0f172a;
font-size: 13px;
line-height: 44px;
text-align: left;
}
.target-suggestion + .target-suggestion {
border-top: 1px solid #eef2f6;
}
.target-suggestion::after {
border: 0;
}
.target-suggestion text:last-child {
color: #64748b;
font-size: 12px;
}
.scenario-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.due-date-field {
display: flex;
align-items: center;
margin-top: 9px;
}
.due-date-field.placeholder {
color: #94a3b8;
}
.scenario-chip {
border-radius: 8px;
}
.scenario-chip.active {
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.submit-button {
width: 100%;
}
.assignment-list {
margin-top: 8px;
}
.assignment-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid #e2e8f0;
}
.assignment-row:last-child {
border-bottom: 0;
}
.assignment-copy {
min-width: 0;
flex: 1;
}
.assignment-title,
.assignment-desc {
display: block;
}
.assignment-goal {
display: block;
margin-top: 5px;
color: #475569;
font-size: 12px;
line-height: 1.4;
}
.assignment-progress-track {
overflow: hidden;
height: 5px;
margin-top: 9px;
border-radius: 999px;
background: #e2e8f0;
}
.assignment-progress-value {
height: 100%;
border-radius: inherit;
background: var(--employee-primary);
}
.assignment-status-copy {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-end;
gap: 7px;
}
.assignment-title {
color: #0f172a;
font-size: 14px;
font-weight: 800;
}
.assignment-desc {
margin-top: 5px;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.assignment-source {
flex: 0 0 auto;
padding: 5px 8px;
border-radius: 8px;
background: #e0f2fe;
color: #0369a1;
font-size: 12px;
font-weight: 800;
}
.assignment-due {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.assignment-due.overdue {
color: #b91c1c;
}
.compact-empty {
padding: 18px 8px 4px;
}
.progress-panel {
margin-top: 14px;
}
.progress-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 14px;
padding: 12px 8px;
border-radius: 14px;
background: var(--employee-primary-soft);
text-align: center;
}
.progress-metrics view {
display: flex;
flex-direction: column;
gap: 4px;
}
.progress-metrics text {
color: var(--employee-primary);
font-size: 20px;
font-weight: 900;
}
.progress-metrics label {
color: var(--employee-muted);
font-size: 11px;
}
.assignment-alert-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.assignment-alert {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-left: 3px solid #f59e0b;
border-radius: 10px;
background: #fff7e6;
}
.assignment-alert.high {
border-left-color: #ef4444;
background: #fff1f2;
}
.assignment-alert text:first-child {
color: #a15c00;
font-size: 13px;
font-weight: 900;
}
.assignment-alert.high text:first-child {
color: #c2410c;
}
.assignment-alert text:last-child {
color: var(--employee-muted);
font-size: 12px;
line-height: 1.45;
}
.detail-empty {
margin-top: 12px;
color: var(--employee-muted);
font-size: 12px;
}
</style>