feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -29,20 +29,44 @@
|
||||
|
||||
<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="员工姓名(可选)"
|
||||
/>
|
||||
<view class="field-heading">
|
||||
<text class="field-label">训练对象</text>
|
||||
<view v-if="teamMembers.length" class="member-actions">
|
||||
<button class="text-button" :disabled="assigning" @click="selectAllMembers">{{ selectAllLabel }}</button>
|
||||
<button class="text-button" :disabled="assigning || !selectedMemberIds.length" @click="clearSelectedMembers">清空</button>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view v-if="teamMembers.length" class="member-list" scroll-y>
|
||||
<button
|
||||
v-for="member in teamMembers"
|
||||
:key="member.extPartyId"
|
||||
class="member-option"
|
||||
:class="{ selected: isMemberSelected(member.extPartyId) }"
|
||||
:aria-pressed="isMemberSelected(member.extPartyId)"
|
||||
:disabled="assigning || historyLoading"
|
||||
@click="toggleTeamMember(member.extPartyId)"
|
||||
>
|
||||
<text class="member-check" aria-hidden="true">{{ isMemberSelected(member.extPartyId) ? '✓' : '' }}</text>
|
||||
<text class="member-name">{{ member.name || '未命名员工' }}</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
<text v-else class="muted block">当前项目暂无可指派的在职员工,请先同步人力系统组织数据。</text>
|
||||
<text v-if="selectedMemberIds.length" class="selected-summary">已选择 {{ selectedMemberIds.length }} 人:{{ selectedMemberNames }}</text>
|
||||
<text v-if="teamMembers.length > maxAssignmentTargets" class="selection-limit">单次最多派发 {{ maxAssignmentTargets }} 人,超出人员请分批派发。</text>
|
||||
|
||||
<text class="field-label section-label">岗位内容</text>
|
||||
<view class="scenario-row position-row">
|
||||
<button
|
||||
v-for="position in assignmentPositions"
|
||||
:key="position"
|
||||
class="chip scenario-chip"
|
||||
:class="{ active: assignmentPosition === position }"
|
||||
:aria-pressed="assignmentPosition === position"
|
||||
:disabled="assigning || scenarioLoading"
|
||||
@click="selectPosition(position)"
|
||||
>{{ position }}</button>
|
||||
</view>
|
||||
<text class="position-note">场景、SOP 与题目按岗位版本加载,不跨岗位混用。</text>
|
||||
|
||||
<text class="field-label section-label">训练场景</text>
|
||||
<view v-if="scenarioOptions.length" class="scenario-row">
|
||||
@@ -51,26 +75,64 @@
|
||||
:key="item.id"
|
||||
class="chip scenario-chip"
|
||||
:class="{ active: assignmentScenarioId === item.id }"
|
||||
:aria-pressed="assignmentScenarioId === item.id"
|
||||
:disabled="assigning"
|
||||
@click="assignmentScenarioId = item.id"
|
||||
@click="selectScenario(item.id)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-else class="muted block">暂无可派发的已启用场景</text>
|
||||
|
||||
<text class="field-label section-label">指派原因</text>
|
||||
<text class="field-label section-label">截止日期</text>
|
||||
<view class="due-date-row">
|
||||
<picker mode="date" :value="dueDate" :start="todayDate" :disabled="assigning" @change="selectDueDate">
|
||||
<view class="input due-date-picker">{{ dueDate }}</view>
|
||||
</picker>
|
||||
<view class="quick-date-row">
|
||||
<button
|
||||
v-for="option in dueDateOptions"
|
||||
:key="option.days"
|
||||
class="chip quick-date-chip"
|
||||
:class="{ active: dueDate === dateAfterDays(option.days) }"
|
||||
:aria-pressed="dueDate === dateAfterDays(option.days)"
|
||||
:disabled="assigning"
|
||||
@click="setDueDate(option.days)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<text class="field-label section-label">指派说明(选填)</text>
|
||||
<textarea
|
||||
v-model="assignmentReason"
|
||||
class="textarea reason-input"
|
||||
maxlength="180"
|
||||
placeholder="填写本次专项训练原因"
|
||||
placeholder="例如:针对近期投诉沟通进行巩固"
|
||||
@input="touchDraft"
|
||||
/>
|
||||
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length" @click="assignPractice">
|
||||
{{ assigning ? '指派中' : '确认指派' }}
|
||||
<button class="primary-button submit-button" :disabled="assigning || historyLoading || !scenarioOptions.length || !selectedMemberIds.length" @click="assignPractice">
|
||||
{{ assigning ? '指派中' : `确认派发(${selectedMemberIds.length}人)` }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="batchResultItems.length" class="panel batch-result-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">本次派发结果</text>
|
||||
<text class="muted">{{ batchResultSummary }}</text>
|
||||
</view>
|
||||
<view class="batch-result-list">
|
||||
<view v-for="item in batchResultItems" :key="`${item.extPartyId}-${item.status}`" class="batch-result-row">
|
||||
<view>
|
||||
<text class="batch-result-name">{{ item.traineeName || '未命名员工' }}</text>
|
||||
<text class="batch-result-message">{{ item.message }}</text>
|
||||
</view>
|
||||
<text class="batch-result-status" :class="item.status.toLowerCase()">{{ batchStatusLabel(item.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel history-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">最近专项</text>
|
||||
@@ -120,10 +182,16 @@
|
||||
<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 type {
|
||||
PracticeAlert,
|
||||
PracticeAssignment,
|
||||
PracticeAssignmentBatchItemResult,
|
||||
PracticeAssignmentBatchItemStatus,
|
||||
PracticeTeamMember
|
||||
} from '@/types/api';
|
||||
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { createPracticeAssignment, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import { createPracticeAssignmentsBatch, getPracticeAlerts, getPracticeScenarios, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import {
|
||||
createAsyncGenerationGuard,
|
||||
@@ -136,12 +204,31 @@ import {
|
||||
} from '@/services/supervisor-view';
|
||||
|
||||
const basePath = '/pages/supervisor/assign/index';
|
||||
const targetExtPartyId = ref('');
|
||||
const targetName = ref('');
|
||||
const maxAssignmentTargets = 100;
|
||||
const pendingBatchStoragePrefix = 'aihr_supervisor_assignment_pending_v1';
|
||||
const pendingBatchTtlMs = 24 * 60 * 60 * 1000;
|
||||
interface PendingBatchDraft {
|
||||
requestId: string;
|
||||
fingerprint: string;
|
||||
selectedMemberIds: string[];
|
||||
scenarioId: string;
|
||||
reason: string;
|
||||
dueDate: string;
|
||||
createdAt: number;
|
||||
}
|
||||
const selectedMemberIds = ref<string[]>([]);
|
||||
const assignmentScenarioId = ref<SupervisorScenarioId>('fee-parking');
|
||||
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
|
||||
const assignmentPositions = ['生活顾问', '客服', '保安', '保洁', '保修'] as const;
|
||||
type AssignmentPosition = typeof assignmentPositions[number];
|
||||
const assignmentPosition = ref<AssignmentPosition>('生活顾问');
|
||||
const scenarioLoading = ref(false);
|
||||
const assignmentReason = ref('');
|
||||
const dueDate = ref('');
|
||||
const pendingRequestId = ref('');
|
||||
const batchResultItems = ref<PracticeAssignmentBatchItemResult[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const teamMembers = ref<PracticeTeamMember[]>([]);
|
||||
const alerts = ref<PracticeAlert[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
@@ -149,13 +236,46 @@ const assigning = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const message = ref('');
|
||||
const historyRequests = createAsyncGenerationGuard();
|
||||
const loginRedirectPath = ref(basePath);
|
||||
|
||||
const selectedTargetLabel = computed(() => {
|
||||
if (!loggedIn.value) return '选择员工并配置训练任务';
|
||||
const target = targetExtPartyId.value.trim();
|
||||
if (!target) return '选择员工并配置训练任务';
|
||||
return targetName.value.trim() ? `${targetName.value.trim()} · ${target}` : target;
|
||||
if (!selectedMemberIds.value.length) return '选择员工并配置训练任务';
|
||||
return `已选择 ${selectedMemberIds.value.length} 人`;
|
||||
});
|
||||
const selectedMemberNames = computed(() => {
|
||||
const names = teamMembers.value
|
||||
.filter((item) => selectedMemberIds.value.includes(item.extPartyId))
|
||||
.map((item) => item.name || '未命名员工');
|
||||
const preview = names.slice(0, 3).join('、');
|
||||
return names.length > 3 ? `${preview} 等 ${names.length} 人` : preview;
|
||||
});
|
||||
const selectAllLabel = computed(() => teamMembers.value.length > maxAssignmentTargets ? `选前${maxAssignmentTargets}人` : '全选');
|
||||
const batchResultSummary = computed(() => {
|
||||
const created = batchResultItems.value.filter((item) => item.status === 'CREATED').length;
|
||||
const duplicate = batchResultItems.value.filter((item) => item.status === 'DUPLICATE').length;
|
||||
const failed = batchResultItems.value.filter((item) => item.status === 'FAILED').length;
|
||||
return `成功 ${created + duplicate} · 失败 ${failed}`;
|
||||
});
|
||||
const dueDateOptions = [
|
||||
{ label: '当天', days: 0 },
|
||||
{ label: '明天', days: 1 },
|
||||
{ label: '3天内', days: 3 },
|
||||
{ label: '7天内', days: 7 }
|
||||
];
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
const dateAfterDays = (days: number) => {
|
||||
const date = new Date();
|
||||
date.setHours(12, 0, 0, 0);
|
||||
date.setDate(date.getDate() + days);
|
||||
return formatDate(date);
|
||||
};
|
||||
const todayDate = dateAfterDays(0);
|
||||
const recentAssignments = computed(() => manualPracticeAssignments(assignments.value).slice(0, 10));
|
||||
const progressCounts = computed(() => {
|
||||
const items = recentAssignments.value;
|
||||
@@ -176,6 +296,86 @@ const queryText = (value: unknown) => {
|
||||
}
|
||||
};
|
||||
|
||||
const assignmentDeepLink = (extPartyId: string, reason: string, scenarioId: string) => {
|
||||
const parameters = [
|
||||
['extPartyId', extPartyId],
|
||||
['reason', reason],
|
||||
['scenarioId', scenarioId]
|
||||
]
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`);
|
||||
return parameters.length ? `${basePath}?${parameters.join('&')}` : basePath;
|
||||
};
|
||||
|
||||
const pendingBatchStorageKey = () => `${pendingBatchStoragePrefix}:${getAuth().phone || 'anonymous'}`;
|
||||
|
||||
const clearPendingBatchDraft = () => {
|
||||
pendingRequestId.value = '';
|
||||
uni.removeStorageSync(pendingBatchStorageKey());
|
||||
};
|
||||
|
||||
const isPendingBatchDraft = (value: unknown): value is PendingBatchDraft => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const draft = value as Record<string, unknown>;
|
||||
return typeof draft.requestId === 'string'
|
||||
&& typeof draft.fingerprint === 'string'
|
||||
&& Array.isArray(draft.selectedMemberIds)
|
||||
&& draft.selectedMemberIds.every((item) => typeof item === 'string' && Boolean(item.trim()))
|
||||
&& typeof draft.scenarioId === 'string'
|
||||
&& typeof draft.reason === 'string'
|
||||
&& typeof draft.dueDate === 'string'
|
||||
&& typeof draft.createdAt === 'number'
|
||||
&& Number.isFinite(draft.createdAt);
|
||||
};
|
||||
|
||||
const readPendingBatchDraft = (): PendingBatchDraft | null => {
|
||||
const stored = uni.getStorageSync(pendingBatchStorageKey()) as unknown;
|
||||
try {
|
||||
const parsed: unknown = typeof stored === 'string' ? JSON.parse(stored) : stored;
|
||||
if (!isPendingBatchDraft(parsed) || !parsed.requestId || !parsed.fingerprint
|
||||
|| parsed.selectedMemberIds.length > maxAssignmentTargets
|
||||
|| !parsed.createdAt || Date.now() - parsed.createdAt > pendingBatchTtlMs) {
|
||||
clearPendingBatchDraft();
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
clearPendingBatchDraft();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const draftFingerprint = (memberIds = selectedMemberIds.value) => JSON.stringify({
|
||||
memberIds: [...memberIds].sort(),
|
||||
scenarioId: assignmentScenarioId.value,
|
||||
reason: assignmentReason.value.trim(),
|
||||
dueDate: dueDate.value
|
||||
});
|
||||
|
||||
const persistPendingBatchDraft = (requestId: string, fingerprint: string) => {
|
||||
const draft: PendingBatchDraft = {
|
||||
requestId,
|
||||
fingerprint,
|
||||
selectedMemberIds: [...selectedMemberIds.value],
|
||||
scenarioId: assignmentScenarioId.value,
|
||||
reason: assignmentReason.value,
|
||||
dueDate: dueDate.value,
|
||||
createdAt: Date.now()
|
||||
};
|
||||
uni.setStorageSync(pendingBatchStorageKey(), draft);
|
||||
};
|
||||
|
||||
const restorePendingBatchDraft = () => {
|
||||
const draft = readPendingBatchDraft();
|
||||
if (!draft) return false;
|
||||
pendingRequestId.value = draft.requestId;
|
||||
selectedMemberIds.value = draft.selectedMemberIds.slice(0, maxAssignmentTargets);
|
||||
assignmentScenarioId.value = draft.scenarioId;
|
||||
assignmentReason.value = draft.reason;
|
||||
dueDate.value = draft.dueDate;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isScenarioId = (value: string): value is SupervisorScenarioId =>
|
||||
scenarioOptions.value.some((item) => item.id === value);
|
||||
|
||||
@@ -184,15 +384,18 @@ const refreshAuthState = () => {
|
||||
};
|
||||
|
||||
const resetDraft = () => {
|
||||
targetExtPartyId.value = '';
|
||||
targetName.value = '';
|
||||
assignmentScenarioId.value = 'fee-parking';
|
||||
selectedMemberIds.value = [];
|
||||
assignmentScenarioId.value = scenarioOptions.value[0]?.id || 'fee-parking';
|
||||
assignmentReason.value = '';
|
||||
dueDate.value = dateAfterDays(1);
|
||||
clearPendingBatchDraft();
|
||||
};
|
||||
|
||||
const clearPrivateState = () => {
|
||||
resetDraft();
|
||||
assignments.value = [];
|
||||
teamMembers.value = [];
|
||||
batchResultItems.value = [];
|
||||
alerts.value = [];
|
||||
historyLoading.value = false;
|
||||
historyRequests.invalidate();
|
||||
@@ -229,8 +432,11 @@ const loadAssignments = async (force = false) => {
|
||||
const [snapshot, nextAlerts] = await Promise.all([getPracticeTeamSnapshot(), getPracticeAlerts()]);
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
accessDenied.value = false;
|
||||
teamMembers.value = snapshot.members || [];
|
||||
assignments.value = snapshot.assignments;
|
||||
alerts.value = nextAlerts;
|
||||
const activeIds = new Set(teamMembers.value.map((item) => item.extPartyId));
|
||||
selectedMemberIds.value = selectedMemberIds.value.filter((id) => activeIds.has(id));
|
||||
}
|
||||
} catch (error) {
|
||||
if (historyRequests.isCurrent(generation)) {
|
||||
@@ -244,8 +450,9 @@ const loadAssignments = async (force = false) => {
|
||||
};
|
||||
|
||||
const loadScenarioOptions = async () => {
|
||||
scenarioLoading.value = true;
|
||||
try {
|
||||
const rows = await getPracticeScenarios('生活顾问');
|
||||
const rows = await getPracticeScenarios(assignmentPosition.value);
|
||||
const nextOptions = rows
|
||||
.filter((item) => item.enabled !== false && item.id && item.name)
|
||||
.map((item) => ({ id: item.id, name: item.name }));
|
||||
@@ -259,10 +466,12 @@ const loadScenarioOptions = async () => {
|
||||
scenarioOptions.value = [];
|
||||
assignmentScenarioId.value = '';
|
||||
} catch {
|
||||
scenarioOptions.value = [...supervisorScenarioOptions];
|
||||
scenarioOptions.value = assignmentPosition.value === '生活顾问' ? [...supervisorScenarioOptions] : [];
|
||||
if (!isScenarioId(assignmentScenarioId.value)) {
|
||||
assignmentScenarioId.value = supervisorScenarioOptions[0].id;
|
||||
}
|
||||
} finally {
|
||||
scenarioLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -278,25 +487,47 @@ const loadPage = () => {
|
||||
|
||||
const assignPractice = async () => {
|
||||
if (assigning.value || historyLoading.value) return;
|
||||
const extPartyId = targetExtPartyId.value.trim();
|
||||
if (!extPartyId) {
|
||||
message.value = '请填写员工手机号或外部ID';
|
||||
const selectedMembers = teamMembers.value.filter((item) => selectedMemberIds.value.includes(item.extPartyId));
|
||||
if (!selectedMembers.length) {
|
||||
message.value = '请至少选择一名员工';
|
||||
return;
|
||||
}
|
||||
if (selectedMembers.length > maxAssignmentTargets) {
|
||||
message.value = `单次最多派发${maxAssignmentTargets}名员工,请分批派发`;
|
||||
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)}`,
|
||||
const fingerprint = draftFingerprint(selectedMembers.map((item) => item.extPartyId));
|
||||
const persistedDraft = readPendingBatchDraft();
|
||||
pendingRequestId.value = persistedDraft?.fingerprint === fingerprint
|
||||
? persistedDraft.requestId
|
||||
: createBatchRequestId();
|
||||
persistPendingBatchDraft(pendingRequestId.value, fingerprint);
|
||||
const result = await createPracticeAssignmentsBatch({
|
||||
requestId: pendingRequestId.value,
|
||||
targets: selectedMembers.map((member) => ({
|
||||
extPartyId: member.extPartyId,
|
||||
traineeName: member.name
|
||||
})),
|
||||
scenarioId: assignmentScenarioId.value,
|
||||
scenarioName: scenario?.name,
|
||||
reason: assignmentReason.value.trim() || '主管派发专项训练'
|
||||
reason: assignmentReason.value.trim() || undefined,
|
||||
dueDate: dueDate.value
|
||||
});
|
||||
resetDraft();
|
||||
uni.showToast({ title: '已指派', icon: 'none' });
|
||||
batchResultItems.value = result.results || [];
|
||||
clearPendingBatchDraft();
|
||||
const completedIds = new Set(batchResultItems.value
|
||||
.filter((item) => item.status !== 'FAILED')
|
||||
.map((item) => item.extPartyId));
|
||||
selectedMemberIds.value = selectedMemberIds.value.filter((id) => !completedIds.has(id));
|
||||
if (!result.failed) {
|
||||
resetDraft();
|
||||
uni.showToast({ title: `已派发${result.created + result.duplicate}人`, icon: 'none' });
|
||||
} else {
|
||||
message.value = `${result.created + result.duplicate}人派发成功,${result.failed}人失败,请查看明细`;
|
||||
}
|
||||
await loadAssignments(true);
|
||||
} catch (error) {
|
||||
handleRequestError(error, '指派失败');
|
||||
@@ -305,6 +536,77 @@ const assignPractice = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const createBatchRequestId = () => {
|
||||
const randomPart = Math.random().toString(36).slice(2, 10);
|
||||
return `assign-${Date.now().toString(36)}-${randomPart}`;
|
||||
};
|
||||
|
||||
const touchDraft = () => {
|
||||
clearPendingBatchDraft();
|
||||
batchResultItems.value = [];
|
||||
};
|
||||
|
||||
const isMemberSelected = (extPartyId: string) => selectedMemberIds.value.includes(extPartyId);
|
||||
|
||||
const toggleTeamMember = (extPartyId: string) => {
|
||||
if (isMemberSelected(extPartyId)) {
|
||||
selectedMemberIds.value = selectedMemberIds.value.filter((id) => id !== extPartyId);
|
||||
} else {
|
||||
if (selectedMemberIds.value.length >= maxAssignmentTargets) {
|
||||
message.value = `单次最多选择${maxAssignmentTargets}名员工,请分批派发`;
|
||||
uni.showToast({ title: `单次最多${maxAssignmentTargets}人`, icon: 'none' });
|
||||
return;
|
||||
}
|
||||
selectedMemberIds.value = [...selectedMemberIds.value, extPartyId];
|
||||
}
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const selectAllMembers = () => {
|
||||
selectedMemberIds.value = teamMembers.value.slice(0, maxAssignmentTargets).map((item) => item.extPartyId);
|
||||
touchDraft();
|
||||
if (teamMembers.value.length > maxAssignmentTargets) {
|
||||
message.value = `已选前${maxAssignmentTargets}人,其余员工请分批派发`;
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelectedMembers = () => {
|
||||
selectedMemberIds.value = [];
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const setDueDate = (days: number) => {
|
||||
dueDate.value = dateAfterDays(days);
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const selectScenario = (scenarioId: string) => {
|
||||
assignmentScenarioId.value = scenarioId;
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const selectPosition = (position: AssignmentPosition) => {
|
||||
if (assignmentPosition.value === position) return;
|
||||
assignmentPosition.value = position;
|
||||
assignmentScenarioId.value = '';
|
||||
scenarioOptions.value = [];
|
||||
touchDraft();
|
||||
void loadScenarioOptions();
|
||||
};
|
||||
|
||||
const selectDueDate = (event: { detail?: { value?: string } }) => {
|
||||
const value = event.detail?.value?.trim();
|
||||
if (!value) return;
|
||||
dueDate.value = value;
|
||||
touchDraft();
|
||||
};
|
||||
|
||||
const batchStatusLabel = (status: PracticeAssignmentBatchItemStatus) => {
|
||||
if (status === 'CREATED') return '成功';
|
||||
if (status === 'DUPLICATE') return '已存在';
|
||||
return '失败';
|
||||
};
|
||||
|
||||
const assignmentSourceLabel = (source: string) => {
|
||||
if (source === 'retry') return '再练';
|
||||
if (source === 'camp') return '训练营';
|
||||
@@ -313,11 +615,13 @@ const assignmentSourceLabel = (source: string) => {
|
||||
|
||||
const assignmentSummary = (item: PracticeAssignment) => {
|
||||
const reason = item.reason?.trim() || '主管派发专项训练';
|
||||
return `${item.createTime} · ${item.status} · ${reason}`;
|
||||
const due = item.dueDate ? `截止 ${item.dueDate}` : '未设置截止日期';
|
||||
const version = item.contentVersion ? `内容 ${item.contentVersion}` : '';
|
||||
return [due, item.status, version, reason].filter(Boolean).join(' · ');
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect(basePath);
|
||||
rememberLoginRedirect(loginRedirectPath.value);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
@@ -327,7 +631,7 @@ const goToEmployee = () => {
|
||||
|
||||
const switchAccount = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect(basePath);
|
||||
rememberLoginRedirect(loginRedirectPath.value);
|
||||
uni.redirectTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
@@ -344,10 +648,23 @@ const goBack = () => {
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
targetExtPartyId.value = queryText(query?.extPartyId);
|
||||
targetName.value = queryText(query?.name);
|
||||
assignmentReason.value = queryText(query?.reason);
|
||||
const requestedExtPartyId = queryText(query?.extPartyId);
|
||||
const requestedReason = queryText(query?.reason);
|
||||
const requestedScenario = queryText(query?.scenarioId);
|
||||
loginRedirectPath.value = assignmentDeepLink(requestedExtPartyId, requestedReason, requestedScenario);
|
||||
const hasExplicitDraft = Boolean(requestedExtPartyId || requestedReason || requestedScenario);
|
||||
const pendingDraft = readPendingBatchDraft();
|
||||
const pendingMatchesDeepLink = Boolean(pendingDraft
|
||||
&& (!requestedExtPartyId || pendingDraft.selectedMemberIds.includes(requestedExtPartyId))
|
||||
&& (!requestedReason || pendingDraft.reason.trim() === requestedReason)
|
||||
&& (!requestedScenario || pendingDraft.scenarioId === requestedScenario));
|
||||
if (pendingDraft && (!hasExplicitDraft || pendingMatchesDeepLink) && restorePendingBatchDraft()) {
|
||||
return;
|
||||
}
|
||||
clearPendingBatchDraft();
|
||||
selectedMemberIds.value = requestedExtPartyId ? [requestedExtPartyId] : [];
|
||||
assignmentReason.value = requestedReason;
|
||||
dueDate.value = dateAfterDays(1);
|
||||
assignmentScenarioId.value = requestedScenario || inferSupervisorScenarioId(assignmentReason.value);
|
||||
});
|
||||
|
||||
@@ -403,6 +720,32 @@ onBeforeUnmount(() => {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.field-heading,
|
||||
.member-actions,
|
||||
.quick-date-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-heading {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--employee-primary);
|
||||
font-size: 12px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.field-input,
|
||||
.reason-input,
|
||||
.scenario-row,
|
||||
@@ -410,16 +753,136 @@ onBeforeUnmount(() => {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.field-input + .field-input {
|
||||
.member-list {
|
||||
max-height: 216px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.member-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: calc(50% - 5px);
|
||||
min-height: 44px;
|
||||
margin: 0 10px 8px 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.member-option:nth-child(2n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.member-option.selected {
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.member-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.member-option.selected .member-check {
|
||||
border-color: var(--employee-primary);
|
||||
background: var(--employee-primary);
|
||||
}
|
||||
|
||||
.member-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selected-summary {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.selection-limit {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #b45309;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.due-date-row {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.due-date-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.quick-date-row {
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.quick-date-chip {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.quick-date-chip.active {
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
}
|
||||
|
||||
.scenario-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.position-row .scenario-chip {
|
||||
flex: 1 1 calc(33.333% - 6px);
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.position-note {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.scenario-chip {
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -433,6 +896,59 @@ onBeforeUnmount(() => {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.batch-result-panel {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.batch-result-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.batch-result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.batch-result-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.batch-result-name,
|
||||
.batch-result-message {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.batch-result-name {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.batch-result-message {
|
||||
margin-top: 3px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.batch-result-status {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.batch-result-status.created {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.batch-result-status.failed {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.assignment-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -516,7 +1032,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.progress-metrics label {
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assignment-alert-list {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<view class="page exam-edit-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回考试列表" :disabled="saving" @click="goBack">←</button>
|
||||
<view class="subpage-heading"><text class="page-title">{{ examId ? '编辑考试' : '新建考试' }}</text><text class="page-subtitle">发布后冻结题目版本,已派发试卷不随草稿变化。</text></view>
|
||||
</view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
|
||||
<view class="panel form-panel">
|
||||
<label class="field-label">考试名称</label>
|
||||
<input v-model="title" class="input" maxlength="80" placeholder="例如:生活顾问岗前基础测试" />
|
||||
<label class="field-label">适用岗位</label>
|
||||
<picker :range="positions" :value="positionIndex" @change="selectPosition"><view class="input picker-value">{{ positions[positionIndex] }}</view></picker>
|
||||
<label class="field-label">及格分</label>
|
||||
<input v-model.number="passingScore" class="input" type="number" />
|
||||
<label class="field-label">截止日期</label>
|
||||
<picker mode="date" :value="dueDate" :start="today" @change="selectDueDate"><view class="input picker-value">{{ dueDate }}</view></picker>
|
||||
<view class="quick-row"><button v-for="item in quickDates" :key="item.days" class="quick-chip" @click="setDueDate(item.days)">{{ item.label }}</button></view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="section-head"><text class="panel-title">考试对象</text><text class="muted">已选 {{ selectedMemberIds.length }} 人</text></view>
|
||||
<view class="member-actions"><button class="ghost-button" @click="selectAll">全选(最多100人)</button><button class="secondary-button" @click="selectedMemberIds = []">清空</button></view>
|
||||
<scroll-view class="member-list" scroll-y>
|
||||
<button v-for="member in members" :key="member.extPartyId" class="member-row" :class="{ selected: selectedMemberIds.includes(member.extPartyId) }" @click="toggleMember(member.extPartyId)">
|
||||
<text class="member-check">{{ selectedMemberIds.includes(member.extPartyId) ? '✓' : '' }}</text><text>{{ member.name || '未命名员工' }}</text>
|
||||
</button>
|
||||
</scroll-view>
|
||||
<text v-if="!members.length" class="muted">当前项目暂无可派发的在职员工。</text>
|
||||
</view>
|
||||
|
||||
<view class="panel question-editor">
|
||||
<view class="section-head"><text class="panel-title">题目({{ questions.length }})· 共 {{ totalScore }} 分</text><button class="ghost-button compact-button" @click="addQuestion">添加题目</button></view>
|
||||
<text v-if="totalScore !== 100" class="score-warning">发布前题目分值合计必须为 100 分</text>
|
||||
<view v-for="(item, index) in questions" :key="item.id" class="question-block">
|
||||
<view class="question-block-head"><text>第 {{ index + 1 }} 题</text><button class="remove-button" :disabled="questions.length === 1" @click="removeQuestion(index)">删除</button></view>
|
||||
<textarea v-model="item.stem" class="textarea stem-input" maxlength="500" placeholder="输入题干" />
|
||||
<picker :range="formats.map((value) => value.label)" :value="formatIndex(item.format)" @change="selectFormat(index, $event)"><view class="input picker-value">{{ formatLabel(item.format) }}</view></picker>
|
||||
<label class="field-label">本题分值</label>
|
||||
<input v-model.number="item.score" class="input" type="number" />
|
||||
<template v-if="item.format !== 'short'">
|
||||
<input v-for="(_, optionIndex) in item.options" :key="optionIndex" v-model="item.options[optionIndex]" class="input" :placeholder="`选项 ${optionIndex + 1}`" />
|
||||
<input v-model="singleAnswerModels[index]" class="input" placeholder="正确答案(与选项文字一致,多选用|分隔)" @input="syncAnswer(index)" />
|
||||
</template>
|
||||
<textarea v-else v-model="singleAnswerModels[index]" class="textarea compact-textarea" placeholder="参考答案" @input="syncAnswer(index)" />
|
||||
<textarea v-model="item.explanation" class="textarea compact-textarea" placeholder="参考依据 / SOP 要点" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="publish-dock">
|
||||
<button class="secondary-button" :disabled="saving || !valid" @click="save(false)">{{ saving ? '保存中' : '保存草稿' }}</button>
|
||||
<button class="primary-button" :disabled="saving || !valid || !selectedMemberIds.length" @click="save(true)">{{ saving ? '发布中' : `发布给 ${selectedMemberIds.length} 人` }}</button>
|
||||
</view>
|
||||
<SupervisorTabBar active="team" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
import type { ExamQuestion, PracticeTeamMember, SupervisorExamDraft } from '@/types/api';
|
||||
import { getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import { getSupervisorExam, publishSupervisorExam, saveSupervisorExam, updateSupervisorExam } from '@/services/exam';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
|
||||
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
|
||||
const formats = [{ key: 'single', label: '单选题' }, { key: 'multiple', label: '多选题' }, { key: 'judge', label: '判断题' }, { key: 'short', label: '简答题' }];
|
||||
const quickDates = [{ label: '当天', days: 0 }, { label: '明天', days: 1 }, { label: '3天内', days: 3 }, { label: '7天内', days: 7 }];
|
||||
const examId = ref(0); const title = ref(''); const positionIndex = ref(0); const passingScore = ref(80); const dueDate = ref('');
|
||||
const members = ref<PracticeTeamMember[]>([]); const selectedMemberIds = ref<string[]>([]); const questions = ref<ExamQuestion[]>([]); const singleAnswerModels = ref<string[]>([]);
|
||||
const saving = ref(false); const message = ref('');
|
||||
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`;
|
||||
const dateAfter = (days: number) => { const date = new Date(); date.setHours(12,0,0,0); date.setDate(date.getDate()+days); return formatDate(date); };
|
||||
const today = dateAfter(0);
|
||||
const requestId = () => `exam-publish:${examId.value || 'new'}:${Date.now()}:${Math.random().toString(36).slice(2,8)}`;
|
||||
const totalScore = computed(() => questions.value.reduce((sum, item) => sum + Number(item.score || 0), 0));
|
||||
const valid = computed(() => title.value.trim().length >= 4
|
||||
&& passingScore.value > 0 && passingScore.value <= 100
|
||||
&& totalScore.value === 100
|
||||
&& questions.value.length > 0
|
||||
&& questions.value.every((item, index) => item.stem.trim()
|
||||
&& Number(item.score) > 0
|
||||
&& singleAnswerModels.value[index]?.trim()
|
||||
&& (item.format === 'short' || item.options.filter((option) => option.trim()).length >= 2)));
|
||||
const goBack = () => uni.navigateBack({ delta: 1 });
|
||||
const selectPosition = (event: { detail: { value: string } }) => { positionIndex.value = Number(event.detail.value || 0); };
|
||||
const selectDueDate = (event: { detail: { value: string } }) => { dueDate.value = event.detail.value; };
|
||||
const setDueDate = (days: number) => { dueDate.value = dateAfter(days); };
|
||||
const formatIndex = (value: string) => Math.max(0, formats.findIndex((item) => item.key === value));
|
||||
const formatLabel = (value: string) => formats.find((item) => item.key === value)?.label || '单选题';
|
||||
const newQuestion = (): ExamQuestion => ({ id: Date.now() + Math.floor(Math.random()*1000), format: 'single', stem: '', options: ['', '', '', ''], score: 100, answer: '', explanation: '' });
|
||||
const rebalanceScores = () => {
|
||||
const count = questions.value.length;
|
||||
if (!count) return;
|
||||
const base = Math.floor(100 / count);
|
||||
questions.value.forEach((item, index) => { item.score = index === count - 1 ? 100 - base * (count - 1) : base; });
|
||||
};
|
||||
const addQuestion = () => { questions.value.push(newQuestion()); singleAnswerModels.value.push(''); rebalanceScores(); };
|
||||
const removeQuestion = (index: number) => { questions.value.splice(index,1); singleAnswerModels.value.splice(index,1); rebalanceScores(); };
|
||||
const selectFormat = (index: number, event: { detail: { value: string } }) => {
|
||||
const format = formats[Number(event.detail.value || 0)]?.key || 'single'; questions.value[index].format = format;
|
||||
questions.value[index].options = format === 'short' ? [] : format === 'judge' ? ['正确','错误'] : (questions.value[index].options.length ? questions.value[index].options : ['','','','']);
|
||||
};
|
||||
const syncAnswer = (index: number) => { questions.value[index].answer = questions.value[index].format === 'multiple' ? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean) : singleAnswerModels.value[index]; };
|
||||
const toggleMember = (id: string) => { selectedMemberIds.value = selectedMemberIds.value.includes(id) ? selectedMemberIds.value.filter((item) => item !== id) : selectedMemberIds.value.length < 100 ? [...selectedMemberIds.value,id] : selectedMemberIds.value; };
|
||||
const selectAll = () => { selectedMemberIds.value = members.value.slice(0,100).map((item) => item.extPartyId); };
|
||||
const draft = (): SupervisorExamDraft => ({
|
||||
title: title.value.trim(),
|
||||
projectCode: '',
|
||||
positionCode: positions[positionIndex.value],
|
||||
passScore: Number(passingScore.value),
|
||||
dueDate: dueDate.value,
|
||||
targets: members.value
|
||||
.filter((item) => selectedMemberIds.value.includes(item.extPartyId))
|
||||
.map((item) => ({ extPartyId: item.extPartyId, traineeName: item.name })),
|
||||
questions: questions.value.map((item, index) => ({
|
||||
...item,
|
||||
answer: item.format === 'multiple'
|
||||
? singleAnswerModels.value[index].split('|').map((value) => value.trim()).filter(Boolean)
|
||||
: singleAnswerModels.value[index]
|
||||
}))
|
||||
});
|
||||
const save = async (publish: boolean) => {
|
||||
if (!valid.value || saving.value) return; saving.value = true; message.value = '';
|
||||
try {
|
||||
const saved = examId.value ? await updateSupervisorExam(examId.value, draft()) : await saveSupervisorExam(draft()); examId.value = saved.id;
|
||||
if (publish) { await publishSupervisorExam(saved.id, requestId()); uni.showToast({ title: '考试已发布', icon: 'none' }); setTimeout(goBack,350); }
|
||||
else message.value = '草稿已保存';
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '考试保存失败'; } finally { saving.value = false; }
|
||||
};
|
||||
const load = async () => {
|
||||
try {
|
||||
const team = await getPracticeTeamSnapshot(); members.value = team.members || [];
|
||||
if (examId.value) {
|
||||
const existing = await getSupervisorExam(examId.value);
|
||||
title.value = existing.title;
|
||||
positionIndex.value = Math.max(0, positions.indexOf(existing.positionCode || '生活顾问'));
|
||||
passingScore.value = existing.passingScore;
|
||||
dueDate.value = existing.dueDate?.slice(0,10) || dateAfter(1);
|
||||
questions.value = existing.questions;
|
||||
singleAnswerModels.value = existing.questions.map((item) => Array.isArray(item.answer) ? item.answer.join('|') : String(item.answer || ''));
|
||||
selectedMemberIds.value = existing.targets.map((item) => item.extPartyId);
|
||||
} else { dueDate.value = dateAfter(1); addQuestion(); }
|
||||
} catch (error) { message.value = error instanceof Error ? error.message : '考试配置加载失败'; }
|
||||
};
|
||||
onLoad((options) => { examId.value = Number(options?.id || 0); void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-edit-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: 190px; }
|
||||
.status-message { display:block; margin-top:12px; color:#a9000d; }
|
||||
.form-panel,.question-editor { display:grid; gap:10px; }
|
||||
.field-label { display:block; margin-top:4px; font-size:13px; font-weight:900; }
|
||||
.picker-value { display:flex; align-items:center; }
|
||||
.two-column { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||
.quick-row,.member-actions,.section-head,.question-block-head { display:flex; gap:8px; align-items:center; justify-content:space-between; }
|
||||
.quick-chip { min-height:36px; padding:0 12px; border:1px solid var(--line); background:#fff; color:var(--muted); }
|
||||
.member-actions button { flex:1; min-width:0; margin-top:10px; }
|
||||
.member-list { max-height:260px; margin-top:10px; }
|
||||
.member-row { display:flex; width:100%; gap:10px; min-height:46px; align-items:center; padding:8px 10px; border-bottom:1px solid var(--line); background:#fff; color:var(--ink); text-align:left; }
|
||||
.member-row.selected { background:var(--employee-primary-soft); }
|
||||
.member-check { flex:0 0 24px; color:var(--brand); font-weight:900; }
|
||||
.compact-button { min-height:36px; padding:0 10px; font-size:12px; }
|
||||
.question-block { display:grid; gap:9px; padding:14px 0; border-top:1px solid var(--line); }
|
||||
.question-block-head { font-weight:900; }
|
||||
.remove-button { background:transparent; color:var(--brand); font-size:13px; }
|
||||
.stem-input { min-height:96px; }
|
||||
.compact-textarea { min-height:82px; }
|
||||
.score-warning { color:#a86100; font-size:12px; font-weight:800; }
|
||||
.publish-dock { position:fixed; z-index:10; right:0; bottom:calc(76px + env(safe-area-inset-bottom)); left:0; display:grid; grid-template-columns:1fr 1fr; gap:10px; max-width:var(--mobile-shell-max-width); margin:0 auto; padding:12px 16px; border-top:1px solid var(--line); background:rgba(255,255,255,.97); }
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<view class="page supervisor-exams-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回主管首页" @click="goBack">←</button>
|
||||
<view class="subpage-heading"><text class="page-title">岗前测试</text><text class="page-subtitle">按岗位组卷、选择真实员工并查看完成情况。</text></view>
|
||||
</view>
|
||||
<button class="primary-button create-button" @click="createExam">新建考试</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view v-if="loading" class="panel">考试列表加载中…</view>
|
||||
<view v-else-if="exams.length" class="exam-list">
|
||||
<view v-for="item in exams" :key="item.id" class="panel exam-card">
|
||||
<view class="exam-head"><text class="panel-title">{{ item.title }}</text><text class="status-chip">{{ item.status }}</text></view>
|
||||
<text class="muted">{{ item.positionCode || '通用岗位' }} · {{ item.questionCount }} 题 · {{ item.targetCount }} 人 · 及格 {{ item.passingScore }} 分</text>
|
||||
<text v-if="item.dueDate" class="muted">截止 {{ item.dueDate }}</text>
|
||||
<view class="exam-actions">
|
||||
<button class="secondary-button" :disabled="item.status !== 'DRAFT'" @click="editExam(item.id)">编辑</button>
|
||||
<button class="ghost-button" @click="viewResults(item.id)">查看结果</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="panel empty">暂无考试,先创建岗位题目并派发。</view>
|
||||
<SupervisorTabBar active="team" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { ExamSummary } from '@/types/api';
|
||||
import { listSupervisorExams } from '@/services/exam';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
const exams = ref<ExamSummary[]>([]); const loading = ref(false); const message = ref('');
|
||||
const goBack = () => uni.redirectTo({ url: '/pages/supervisor/index/index' });
|
||||
const createExam = () => uni.navigateTo({ url: '/pages/supervisor/exams/edit' });
|
||||
const editExam = (id: number) => uni.navigateTo({ url: `/pages/supervisor/exams/edit?id=${id}` });
|
||||
const viewResults = (id: number) => uni.navigateTo({ url: `/pages/supervisor/exams/results?id=${id}` });
|
||||
const load = async () => { loading.value = true; try { exams.value = await listSupervisorExams(); } catch (error) { message.value = error instanceof Error ? error.message : '考试列表读取失败'; } finally { loading.value = false; } };
|
||||
onShow(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supervisor-exams-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; }
|
||||
.create-button { width: 100%; margin-top: 18px; }
|
||||
.status-message { display: block; margin-top: 12px; color: #a9000d; }
|
||||
.exam-list { display: grid; gap: 4px; }
|
||||
.exam-card { display: grid; gap: 9px; }
|
||||
.exam-head, .exam-actions { display: flex; gap: 10px; align-items: center; justify-content: space-between; }
|
||||
.status-chip { padding: 4px 8px; border-radius: 999px; background: #eef3f9; color: var(--knowledge); font-size: 12px; }
|
||||
.exam-actions button { flex: 1; min-width: 0; }
|
||||
.empty { color: var(--muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<view class="page exam-results-page">
|
||||
<view class="subpage-header"><button class="subpage-back-button" aria-label="返回" @click="goBack">←</button><view class="subpage-heading"><text class="page-title">考试结果</text><text class="page-subtitle">按人员查看成绩、未完成与通过状态。</text></view></view>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
<view class="metric-grid">
|
||||
<view class="metric-card"><text>{{ results.length }}</text><label>已派发</label></view>
|
||||
<view class="metric-card"><text>{{ completed }}</text><label>已完成</label></view>
|
||||
<view class="metric-card"><text>{{ passed }}</text><label>已通过</label></view>
|
||||
</view>
|
||||
<view v-if="results.length" class="result-list">
|
||||
<view v-for="item in results" :key="item.attemptId || item.extPartyId" class="panel result-row">
|
||||
<view><text class="result-name">{{ item.traineeName }}</text><text class="muted">{{ item.submittedTime || '尚未提交' }} · {{ item.status === 'PENDING' ? '待完成' : '已提交' }}</text></view>
|
||||
<view class="result-score"><text>{{ item.score == null ? '--' : `${item.score}分` }}</text><label>{{ item.status === 'PENDING' ? '未完成' : item.passed ? '通过' : '未通过' }}</label></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="panel empty">暂无派发结果</view>
|
||||
<SupervisorTabBar active="team" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'; import { onLoad } from '@dcloudio/uni-app'; import type { ExamResultItem } from '@/types/api'; import { getSupervisorExamResults } from '@/services/exam'; import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
const id=ref(0); const results=ref<ExamResultItem[]>([]); const message=ref(''); const completed=computed(()=>results.value.filter((item)=>item.status==='SUBMITTED').length); const passed=computed(()=>results.value.filter((item)=>item.status==='SUBMITTED'&&item.passed===true).length);
|
||||
const goBack=()=>uni.navigateBack({delta:1}); const load=async()=>{try{results.value=await getSupervisorExamResults(id.value);}catch(error){message.value=error instanceof Error?error.message:'考试结果读取失败';}}; onLoad((options)=>{id.value=Number(options?.id||0);void load();});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-results-page{max-width:var(--mobile-shell-max-width);margin:0 auto}.status-message{display:block;margin-top:12px;color:#a9000d}.metric-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:18px}.metric-card{display:grid;gap:4px;padding:14px;border:1px solid var(--line);border-radius:12px;background:#fff;text-align:center}.metric-card text{font-size:22px;font-weight:900}.metric-card label,.muted{color:var(--muted);font-size:12px}.result-list{display:grid;gap:4px}.result-row{display:flex;gap:12px;align-items:center;justify-content:space-between}.result-name,.result-row .muted,.result-score text,.result-score label{display:block}.result-name{font-weight:900}.result-score{text-align:right}.result-score text{color:var(--brand);font-weight:900}.result-score label{margin-top:4px;color:var(--muted);font-size:12px}.empty{color:var(--muted)}
|
||||
</style>
|
||||
@@ -120,6 +120,13 @@
|
||||
</view>
|
||||
<text class="task-arrow">›</text>
|
||||
</button>
|
||||
<button class="task-row" @click="openPage('/pages/supervisor/exams/index')">
|
||||
<view class="task-copy">
|
||||
<text class="task-title">岗前测试</text>
|
||||
<text class="task-meta">组卷、派发并查看考试结果</text>
|
||||
</view>
|
||||
<text class="task-arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -129,7 +136,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
PracticeAlert,
|
||||
PracticeAssignment,
|
||||
@@ -146,6 +153,7 @@ import {
|
||||
buildTeamProfiles,
|
||||
isSupervisorAccessDenied
|
||||
} from '@/services/supervisor-view';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
|
||||
const currentPath = '/pages/supervisor/index/index';
|
||||
const pendingReviews = ref<PracticeRecord[]>([]);
|
||||
@@ -157,6 +165,8 @@ const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const supervisorMetrics = computed(() =>
|
||||
buildSupervisorMetrics(pendingReviews.value, assignments.value, alerts.value));
|
||||
@@ -198,6 +208,7 @@ const handleRequestError = (error: unknown, fallback: string) => {
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
const request = requests.begin('page');
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
@@ -205,7 +216,6 @@ const loadPage = async () => {
|
||||
clearData();
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [nextReviews, nextAlerts, snapshot] = await Promise.all([
|
||||
@@ -213,6 +223,7 @@ const loadPage = async () => {
|
||||
getPracticeAlerts(),
|
||||
getPracticeTeamSnapshot()
|
||||
]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
accessDenied.value = false;
|
||||
pendingReviews.value = nextReviews;
|
||||
alerts.value = nextAlerts;
|
||||
@@ -220,12 +231,18 @@ const loadPage = async () => {
|
||||
assignments.value = snapshot.assignments;
|
||||
members.value = snapshot.members;
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '主管端数据加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requests.isCurrent(request)) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const openPage = (url: string) => {
|
||||
uni.navigateTo({ url });
|
||||
};
|
||||
@@ -254,9 +271,20 @@ const switchAccount = () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
clearData();
|
||||
message.value = '';
|
||||
accessDenied.value = false;
|
||||
}
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void loadPage();
|
||||
});
|
||||
|
||||
onHide(invalidateRequests);
|
||||
onUnload(invalidateRequests);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel learner-mode-card">
|
||||
<text class="panel-title">我的学习</text>
|
||||
<text class="muted learner-mode-desc">以学员界面完成经理安排的学习与训练,不会修改你在人力系统中的主管身份。</text>
|
||||
<button class="primary-button learner-mode-button" @click="enterLearnerMode">进入学员模式</button>
|
||||
</view>
|
||||
|
||||
<button class="secondary-button logout-button" @click="logout">退出登录</button>
|
||||
<SupervisorTabBar active="profile" />
|
||||
</view>
|
||||
@@ -34,7 +40,13 @@
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import {
|
||||
clearAuth,
|
||||
enterSupervisorLearnerMode,
|
||||
getAuth,
|
||||
isLoggedIn,
|
||||
rememberLoginRedirect
|
||||
} from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
|
||||
const phone = ref('');
|
||||
@@ -43,6 +55,14 @@ const refreshAuth = () => {
|
||||
phone.value = getAuth().phone;
|
||||
};
|
||||
|
||||
const enterLearnerMode = () => {
|
||||
if (!enterSupervisorLearnerMode()) {
|
||||
uni.showToast({ title: '当前账号不是主管', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
uni.switchTab({ url: '/pages/user/today/index' });
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearAuth();
|
||||
rememberLoginRedirect('/pages/supervisor/profile/index');
|
||||
@@ -105,4 +125,19 @@ onShow(() => {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.learner-mode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.learner-mode-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.learner-mode-button {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -48,11 +48,14 @@
|
||||
|
||||
<view class="panel mistake-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">团队错题本</text>
|
||||
<text class="muted">按项目范围聚合</text>
|
||||
<text class="panel-title">常见难题</text>
|
||||
<text class="muted">近30天 · 仅当前项目</text>
|
||||
</view>
|
||||
<view v-if="mistakes.length" class="mistake-list">
|
||||
<view v-for="item in mistakes" :key="`${item.scenarioId}-${item.cause}`" class="mistake-row">
|
||||
<view class="difficulty-sort" role="group" aria-label="难题排序">
|
||||
<button v-for="item in difficultySorts" :key="item.key" :class="{ active: difficultySort === item.key }" @click="difficultySort = item.key">{{ item.label }}</button>
|
||||
</view>
|
||||
<view v-if="sortedDifficulties.length" class="mistake-list">
|
||||
<view v-for="item in sortedDifficulties" :key="`${item.scenarioId}-${item.cause}`" class="mistake-row">
|
||||
<view class="mistake-main">
|
||||
<view class="mistake-title-row">
|
||||
<text class="mistake-title">{{ item.scenarioName }}</text>
|
||||
@@ -61,7 +64,9 @@
|
||||
<text class="mistake-cause">{{ item.cause }} · {{ item.affectedPeople }} 名成员 · 平均 {{ item.averageScore }} 分</text>
|
||||
<text class="mistake-time">最近发生 {{ item.latestTime || '暂无时间' }}</text>
|
||||
</view>
|
||||
<button class="secondary-button mistake-action" @click="openReviews">去待复盘</button>
|
||||
<button class="secondary-button mistake-action" :disabled="assigningDifficultyKey === `${item.scenarioId}-${item.cause}`" @click="assignDifficulty(item)">
|
||||
{{ assigningDifficultyKey === `${item.scenarioId}-${item.cause}` ? '派发中' : '一键派专项' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty compact-empty">暂无团队错题记录</view>
|
||||
@@ -140,11 +145,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, PracticeMistakeAggregate, PracticeRecord, PracticeTeamMember } from '@/types/api';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type { PracticeAssignment, PracticeDifficulty, PracticeRecord, PracticeTeamMember } from '@/types/api';
|
||||
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import { assignPracticeDifficulty, getPracticeDifficulties, getPracticeTeamSnapshot } from '@/services/practice';
|
||||
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
|
||||
import {
|
||||
buildTeamProfiles,
|
||||
@@ -152,17 +157,29 @@ import {
|
||||
isSupervisorAccessDenied,
|
||||
type TeamProfile
|
||||
} from '@/services/supervisor-view';
|
||||
import { createPageRequestScope, currentAccountKey } from '@/services/request-scope';
|
||||
|
||||
const currentPath = '/pages/supervisor/team/index';
|
||||
const records = ref<PracticeRecord[]>([]);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const members = ref<PracticeTeamMember[]>([]);
|
||||
const mistakes = ref<PracticeMistakeAggregate[]>([]);
|
||||
const mistakes = ref<PracticeDifficulty[]>([]);
|
||||
const loggedIn = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const selectedMember = ref<TeamProfile | null>(null);
|
||||
const difficultySorts = [
|
||||
{ key: 'count', label: '出现次数' },
|
||||
{ key: 'people', label: '涉及人数' },
|
||||
{ key: 'score', label: '低分优先' },
|
||||
{ key: 'latest', label: '最近发生' }
|
||||
] as const;
|
||||
type DifficultySort = typeof difficultySorts[number]['key'];
|
||||
const difficultySort = ref<DifficultySort>('count');
|
||||
const assigningDifficultyKey = ref('');
|
||||
const requests = createPageRequestScope();
|
||||
let visibleAccountKey = '';
|
||||
|
||||
const teamProfiles = computed(() => buildTeamProfiles(records.value, assignments.value, members.value));
|
||||
const riskCounts = computed(() => teamProfiles.value.reduce(
|
||||
@@ -178,6 +195,12 @@ const memberRecords = computed(() => {
|
||||
.filter((item) => item.extPartyId === selectedMember.value?.extPartyId)
|
||||
.slice(0, 6);
|
||||
});
|
||||
const sortedDifficulties = computed(() => [...mistakes.value].sort((a, b) => {
|
||||
if (difficultySort.value === 'people') return b.affectedPeople - a.affectedPeople;
|
||||
if (difficultySort.value === 'score') return a.averageScore - b.averageScore;
|
||||
if (difficultySort.value === 'latest') return String(b.latestTime).localeCompare(String(a.latestTime));
|
||||
return b.count - a.count;
|
||||
}));
|
||||
|
||||
const clearData = () => {
|
||||
records.value = [];
|
||||
@@ -209,6 +232,7 @@ const handleRequestError = (error: unknown, fallback: string) => {
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
const request = requests.begin('page');
|
||||
refreshAuthState();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
@@ -216,19 +240,20 @@ const loadPage = async () => {
|
||||
clearData();
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const snapshot = await getPracticeTeamSnapshot();
|
||||
const [snapshot, difficulties] = await Promise.all([getPracticeTeamSnapshot(), getPracticeDifficulties(30)]);
|
||||
if (!requests.isCurrent(request)) return;
|
||||
accessDenied.value = false;
|
||||
records.value = snapshot.records;
|
||||
assignments.value = snapshot.assignments;
|
||||
members.value = snapshot.members;
|
||||
mistakes.value = snapshot.mistakes ?? [];
|
||||
mistakes.value = difficulties?.length ? difficulties : (snapshot.mistakes ?? []);
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '团队画像加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requests.isCurrent(request)) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -247,8 +272,43 @@ const selectMember = (member: TeamProfile) => {
|
||||
selectedMember.value = selectedMember.value?.key === member.key ? null : member;
|
||||
};
|
||||
|
||||
const openReviews = () => {
|
||||
uni.navigateTo({ url: '/pages/supervisor/reviews/index' });
|
||||
const tomorrow = () => {
|
||||
const date = new Date();
|
||||
date.setHours(12, 0, 0, 0);
|
||||
date.setDate(date.getDate() + 1);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const assignDifficulty = async (item: PracticeDifficulty) => {
|
||||
const key = `${item.scenarioId}-${item.cause}`;
|
||||
if (assigningDifficultyKey.value) return;
|
||||
const request = requests.begin('assign-difficulty');
|
||||
assigningDifficultyKey.value = key;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await assignPracticeDifficulty({
|
||||
scenarioId: item.scenarioId,
|
||||
cause: item.cause,
|
||||
dueDate: tomorrow(),
|
||||
requestId: `difficulty:${item.scenarioId}:${Date.now()}`,
|
||||
windowDays: 30,
|
||||
position: item.position
|
||||
});
|
||||
if (!requests.isCurrent(request)) return;
|
||||
message.value = `专项已派发:成功 ${result.created + result.duplicate} 人,失败 ${result.failed} 人`;
|
||||
await loadPage();
|
||||
} catch (error) {
|
||||
if (!requests.isCurrent(request)) return;
|
||||
handleRequestError(error, '常见难题专项派发失败');
|
||||
} finally {
|
||||
if (requests.isCurrent(request)) assigningDifficultyKey.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateRequests = () => {
|
||||
requests.invalidate();
|
||||
loading.value = false;
|
||||
assigningDifficultyKey.value = '';
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
@@ -278,9 +338,20 @@ const goBack = () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const nextAccountKey = currentAccountKey();
|
||||
if (visibleAccountKey && visibleAccountKey !== nextAccountKey) {
|
||||
clearData();
|
||||
message.value = '';
|
||||
accessDenied.value = false;
|
||||
}
|
||||
visibleAccountKey = nextAccountKey;
|
||||
requests.activate();
|
||||
resetPageScroll();
|
||||
void loadPage();
|
||||
});
|
||||
|
||||
onHide(invalidateRequests);
|
||||
onUnload(invalidateRequests);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -372,6 +443,29 @@ onShow(() => {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.difficulty-sort {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.difficulty-sort button {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d7e0eb;
|
||||
background: #ffffff;
|
||||
color: var(--employee-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.difficulty-sort button.active {
|
||||
border-color: #ef9da5;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.mistake-list {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -427,7 +521,7 @@ onShow(() => {
|
||||
.mistake-time {
|
||||
margin-top: 5px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -509,7 +603,7 @@ onShow(() => {
|
||||
border-radius: 8px;
|
||||
background: var(--employee-primary-soft);
|
||||
color: var(--employee-primary);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -563,7 +657,7 @@ onShow(() => {
|
||||
.member-summary-label {
|
||||
margin-top: 4px;
|
||||
color: var(--employee-muted);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subsection-title {
|
||||
|
||||
Reference in New Issue
Block a user