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

566 lines
15 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="panel assignment-form">
<text class="field-label">训练对象</text>
<input
v-model="targetExtPartyId"
class="input field-input"
maxlength="32"
placeholder="输入手机号或外部ID"
@input="targetName = ''"
/>
<input
v-model="targetName"
class="input field-input"
maxlength="40"
placeholder="员工姓名(可选)"
/>
<text class="field-label section-label">训练场景</text>
<view v-if="scenarioOptions.length" class="scenario-row">
<button
v-for="item in scenarioOptions"
: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>
<textarea
v-model="assignmentReason"
class="textarea reason-input"
maxlength="180"
placeholder="填写本次专项训练原因"
/>
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length" @click="assignPractice">
{{ assigning ? '指派中' : '确认指派' }}
</button>
</view>
<view 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>
</view>
<text class="assignment-source">{{ assignmentSourceLabel(item.source) }}</text>
</view>
</view>
<view v-else-if="!historyLoading" class="empty compact-empty">暂无专项指派记录</view>
</view>
<view 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 } from '@/types/api';
import { clearAuth, isLoggedIn, 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 targetExtPartyId = ref('');
const targetName = ref('');
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
const assignmentReason = ref('');
const assignments = ref<PracticeAssignment[]>([]);
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 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';
assignmentReason.value = '';
};
const clearPrivateState = () => {
resetDraft();
assignments.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;
alerts.value = nextAlerts;
}
} catch (error) {
if (historyRequests.isCurrent(generation)) {
handleRequestError(error, '最近专项加载失败');
}
} finally {
if (historyRequests.isCurrent(generation)) {
historyLoading.value = false;
}
}
};
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() || '主管派发专项训练'
});
resetDraft();
uni.showToast({ title: '已指派', icon: 'none' });
await loadAssignments(true);
} 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 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;
}
.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,
.scenario-row,
.submit-button {
margin-top: 9px;
}
.field-input + .field-input {
margin-top: 10px;
}
.scenario-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.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-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;
}
.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>