feat(aihr): finalize practice growth and access boundaries

This commit is contained in:
2026-07-25 00:42:19 +08:00
parent a90f40e4d8
commit c9d2012ec1
87 changed files with 6804 additions and 2538 deletions
@@ -42,6 +42,18 @@
<text class="head-title">{{ displayName }} · {{ detail.scene }}</text>
</view>
<text class="head-meta">{{ detail.time }} · {{ detail.status }}</text>
<view class="curriculum-summary">
<text class="curriculum-summary-label">训练归属</text>
<view class="curriculum-summary-tags">
<text class="curriculum-summary-tag">{{ detail.projectType || '住宅' }}</text>
<text class="curriculum-summary-tag">{{ detail.position || '岗位待补录' }}</text>
<text class="curriculum-summary-tag">{{ detail.growthLevel || '层级待补录' }}</text>
<text class="curriculum-summary-tag">{{ detail.competencyCode || '能力项待归类' }}</text>
</view>
<text v-if="detail.collaborationPositions" class="curriculum-summary-note">协同岗位:{{ detail.collaborationPositions }}</text>
<text v-if="detail.curriculumVersion && detail.curriculumVersion !== '历史未记录'" class="curriculum-summary-note">目录版本:{{ detail.curriculumVersion }}(本次训练快照)</text>
<text v-else class="curriculum-summary-note">目录版本未记录;该训练早于课程快照机制,不能按当前场景倒推。</text>
</view>
<view class="ai-summary">
<view class="ai-summary-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"/></svg>
@@ -184,7 +196,7 @@
:class="{ active: nextScenarioId === item.id }"
@click="selectNextScenario(item.id)"
>
{{ item.name }}
{{ item.name }}<text v-if="item.growthLevel || item.competencyCode"> · {{ item.growthLevel || '基础' }}{{ item.competencyCode ? ` · ${item.competencyCode}` : '' }}</text>
</button>
</view>
<textarea
@@ -196,6 +208,23 @@
/>
</view>
<view v-if="nextConfirmableGrowthLevel" class="next-training-section">
<text class="field-label">训练层级确认(可选)</text>
<view class="scenario-row">
<button class="scenario-chip" :class="{ active: !confirmedGrowthLevel }" @click="confirmedGrowthLevel = ''">
暂不确认
</button>
<button
class="scenario-chip"
:class="{ active: confirmedGrowthLevel === nextConfirmableGrowthLevel }"
@click="confirmedGrowthLevel = nextConfirmableGrowthLevel"
>
确认可进入{{ nextConfirmableGrowthLevel }}
</button>
</view>
<text class="growth-confirmation-note">仅记录训练层级证据,不调整职级、薪酬或上岗资格。</text>
</view>
<button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed">
{{ marking ? '提交中…' : '标记已复盘' }}
</button>
@@ -207,6 +236,9 @@
<text v-if="detail.incentivePoint != null" class="complete-point">
人工贡献分 {{ detail.incentivePoint }}
</text>
<text v-if="detail.confirmedGrowthLevel" class="complete-point">
已确认可进入{{ detail.confirmedGrowthLevel }}{{ detail.growthConfirmedTime ? ` · ${detail.growthConfirmedTime}` : '' }}
</text>
</view>
</template>
@@ -226,7 +258,7 @@ import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { ReviewDetail, ReviewDialogue } from '@/types/api';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { clearAuth, isLoggedIn, rememberLoginRedirect, type UserPosition } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeReviewDetail, getPracticeScenarios, getPracticeTeamSnapshot, markPracticeReviewed } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
@@ -235,7 +267,6 @@ import {
createAsyncGenerationGuard,
isReviewEditable,
isSupervisorAccessDenied,
supervisorScenarioOptions,
type SupervisorScenarioOption,
type SupervisorScenarioId
} from '@/services/supervisor-view';
@@ -246,8 +277,9 @@ const detail = ref<ReviewDetail | null>(null);
const advice = ref('');
const incentivePoint = ref('');
const nextScenarioId = ref<SupervisorScenarioId | ''>('');
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
const scenarioOptions = ref<SupervisorScenarioOption[]>([]);
const nextReason = ref('');
const confirmedGrowthLevel = ref('');
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
@@ -258,7 +290,16 @@ const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const audioRequests = createAsyncGenerationGuard();
const memberNames = ref<Record<string, string>>({});
const scenarioPosition = (value?: string): UserPosition | null => {
const known: UserPosition[] = ['生活顾问', '保安', '保洁', '保修', '客服'];
return known.includes(value as UserPosition) ? value as UserPosition : null;
};
const editable = computed(() => isReviewEditable(detail.value?.status));
const nextConfirmableGrowthLevel = computed(() => {
if (detail.value?.growthLevel === '初级') return '中级';
if (detail.value?.growthLevel === '中级') return '高级';
return '';
});
const loadFailed = computed(() => !loading.value && !!message.value && !detail.value);
const firstLoading = computed(() => loading.value && !detail.value && !message.value);
@@ -315,6 +356,7 @@ const clearDetail = () => {
incentivePoint.value = '';
nextScenarioId.value = '';
nextReason.value = '';
confirmedGrowthLevel.value = '';
};
const refreshAuthState = () => {
@@ -410,6 +452,8 @@ const loadDetail = async () => {
incentivePoint.value = detail.value.incentivePoint == null ? '' : String(detail.value.incentivePoint);
nextScenarioId.value = '';
nextReason.value = '';
confirmedGrowthLevel.value = '';
void loadScenarioOptions();
} catch (error) {
clearDetail();
handleRequestError(error, '复盘详情加载失败');
@@ -419,18 +463,30 @@ const loadDetail = async () => {
};
const loadScenarioOptions = async () => {
const position = scenarioPosition(detail.value?.position);
if (!position) {
scenarioOptions.value = [];
nextScenarioId.value = '';
return;
}
try {
const rows = await getPracticeScenarios('生活顾问');
const rows = await getPracticeScenarios(position);
const nextOptions = rows
.filter((item) => item.enabled !== false && item.id && item.name)
.map((item) => ({ id: item.id, name: item.name }));
.filter((item) => item.enabled !== false && (!item.reviewStatus || item.reviewStatus === '已发布') && item.id && item.name)
.map((item) => ({
id: item.id,
name: item.name,
growthLevel: item.growthLevel,
competencyCode: item.competencyCode
}));
if (nextOptions.length) {
scenarioOptions.value = nextOptions;
} else {
scenarioOptions.value = [];
}
} catch {
scenarioOptions.value = [...supervisorScenarioOptions];
scenarioOptions.value = [];
nextScenarioId.value = '';
}
};
@@ -469,7 +525,8 @@ const markReviewed = async () => {
advice: advice.value.trim(),
incentivePoint: point,
nextScenarioId: nextScenarioId.value || undefined,
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined,
confirmedGrowthLevel: confirmedGrowthLevel.value || undefined
});
const params = new URLSearchParams({ id: String(detail.value.id) });
if (assignedScenario) params.set('assigned', assignedScenario);
@@ -691,6 +748,51 @@ onBeforeUnmount(() => {
line-height: 1.4;
}
.curriculum-summary {
margin-top: 11px;
padding: 10px 11px;
border-radius: 12px;
background: #f7faff;
}
.curriculum-summary-label,
.curriculum-summary-note {
display: block;
}
.curriculum-summary-label {
color: #315b8f;
font-size: 11px;
font-weight: 800;
}
.curriculum-summary-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 7px;
}
.curriculum-summary-tag {
max-width: 100%;
padding: 3px 7px;
overflow: hidden;
border-radius: 999px;
background: #eaf2ff;
color: #4b6381;
font-size: 10px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.curriculum-summary-note {
margin-top: 5px;
color: #778395;
font-size: 10px;
line-height: 1.45;
}
.head-score {
display: flex;
flex: 0 0 auto;
@@ -1108,6 +1210,14 @@ onBeforeUnmount(() => {
line-height: 1.5;
}
.growth-confirmation-note {
display: block;
margin-top: 8px;
color: #7b8798;
font-size: 12px;
line-height: 1.55;
}
.submit-button {
display: flex;
width: 100%;