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

433 lines
11 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 class="scenario-row">
<button
v-for="item in supervisorScenarioOptions"
:key="item.id"
class="chip scenario-chip"
:class="{ active: assignmentScenarioId === item.id }"
:disabled="assigning"
@click="assignmentScenarioId = item.id"
>
{{ item.name }}
</button>
</view>
<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" @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>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { PracticeAssignment } from '@/types/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { createPracticeAssignment, getPracticeTeamSnapshot } from '@/services/practice';
import {
createAsyncGenerationGuard,
inferSupervisorScenarioId,
isSupervisorAccessDenied,
manualPracticeAssignments,
supervisorScenarioOptions,
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 assignmentReason = ref('');
const assignments = ref<PracticeAssignment[]>([]);
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 queryText = (value: unknown) => {
if (typeof value !== 'string') return '';
try {
return decodeURIComponent(value).trim();
} catch {
return value.trim();
}
};
const isScenarioId = (value: string): value is SupervisorScenarioId =>
supervisorScenarioOptions.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 = [];
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 = await getPracticeTeamSnapshot();
if (historyRequests.isCurrent(generation)) {
accessDenied.value = false;
assignments.value = snapshot.assignments;
}
} catch (error) {
if (historyRequests.isCurrent(generation)) {
handleRequestError(error, '最近专项加载失败');
}
} finally {
if (historyRequests.isCurrent(generation)) {
historyLoading.value = false;
}
}
};
const loadPage = () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
clearPrivateState();
return;
}
void loadAssignments();
};
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 = supervisorScenarioOptions.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 = isScenarioId(requestedScenario)
? requestedScenario
: inferSupervisorScenarioId(assignmentReason.value);
});
onShow(() => {
resetPageScroll();
loadPage();
});
onBeforeUnmount(() => {
historyRequests.dispose();
});
</script>
<style scoped>
.assign-page {
padding-bottom: 42px;
background: #f4f7fb;
}
.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: #fee2e2;
color: #b91c1c;
}
.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;
}
</style>