Files
prop-ai-hr/mobile-uni/src/pages/user/profile/index.vue
T

647 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 profile-page employee-page-shell">
<view class="profile-head">
<text class="profile-eyebrow">银城物业 · 员工成长</text>
<text class="page-title">我的成长</text>
<text class="page-subtitle">{{ phone || '登录后查看训练记录和能力画像' }}</text>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view class="panel account-card">
<view>
<text class="panel-title">账号</text>
<text class="account-phone">{{ phone || '未登录' }}</text>
</view>
<button v-if="!loggedIn" class="primary-button account-action" @click="goLogin">去登录</button>
<button v-else class="secondary-button account-action" @click="logout">退出</button>
</view>
<view v-if="loggedIn" class="panel position-panel">
<view class="position-current">
<view>
<text class="panel-title">岗位身份</text>
<text class="position-title">{{ positionProfile.value }} · {{ positionProfile.heroTitle }}</text>
</view>
</view>
<view class="position-actions">
<button
v-for="item in positionCards"
:key="item.value"
class="position-chip"
:class="{ active: item.value === positionProfile.value }"
@click="changePosition(item.value)"
>
{{ item.value }}
</button>
</view>
</view>
<view v-if="profile" class="panel profile-summary">
<text class="panel-title">成长摘要</text>
<text class="ai-score-disclaimer">AI评分仅作训练参考,人可复核/否决,不直接决定绩效或晋升</text>
<view class="score-row">
<view class="score-main">
<text class="score-value">{{ profile.score }}</text>
<text class="score-label">综合能力</text>
</view>
<view class="metric-grid">
<view class="metric">
<text>{{ profile.completed }}</text>
<text>已训练</text>
</view>
<view class="metric">
<text>{{ profile.pendingReview }}</text>
<text>待复盘</text>
</view>
<view class="metric">
<text>{{ profile.trainingMinutes || 0 }}</text>
<text>分钟</text>
</view>
<view class="metric">
<text>{{ profile.aiLevel || '未评级' }}</text>
<text>训练画像等级</text>
</view>
</view>
</view>
<view v-if="profile.dimensions?.length" class="dimension-list">
<view v-for="item in profile.dimensions" :key="item.label" class="dimension-row">
<view>
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-score" :class="{ pending: item.value === null || item.value === undefined }">
{{ item.value === null || item.value === undefined ? '待采集' : item.value }}
</text>
</view>
</view>
<view v-if="profile.growthPath?.length" class="growth-section">
<text class="section-label">成长路径</text>
<view v-for="stage in profile.growthPath" :key="stage.level" class="growth-row">
<view class="growth-copy">
<text class="growth-title">{{ stage.level }} · {{ stage.status }}</text>
<text class="growth-desc">{{ stage.evidence }}</text>
</view>
<view class="growth-progress">
<view class="growth-bar">
<view class="growth-fill" :style="{ width: `${stage.progress}%` }" />
</view>
<text>{{ stage.progress }}%</text>
</view>
</view>
</view>
</view>
<view v-if="evidence" class="panel evidence-panel">
<text class="panel-title">成长证据包(待人工复核)</text>
<view class="evidence-grid">
<view class="evidence-metric">
<text>{{ evidence.currentLevel }}</text>
<text>参考等级</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.assessmentScore }}</text>
<text>测评分</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.trainingMinutes }}</text>
<text>训练分钟</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.contribution }}</text>
<text>贡献度</text>
</view>
</view>
<view v-if="evidence.evidenceItems?.length" class="evidence-list">
<text v-for="item in evidence.evidenceItems" :key="item" class="evidence-item">{{ item }}</text>
</view>
</view>
<view v-if="loggedIn" class="panel history-panel">
<view class="history-head">
<text class="panel-title">最近练过</text>
<button class="ghost-button refresh-button" :disabled="loading" @click="loadData">刷新</button>
</view>
<view v-if="history.length" class="history-list">
<view
v-for="item in history"
:key="item.id || item.sessionId || item.time"
class="history-row history-row-clickable"
@click="openHistory(item.id)"
>
<view>
<text class="history-title">{{ item.scene }}</text>
<text class="history-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<view class="history-score">
<text>{{ item.score }}</text>
<text>{{ item.status }}</text>
</view>
</view>
</view>
<view v-else class="empty">暂无训练记录</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
import {
clearAuth,
getAuth,
getPositionProfile,
getSelectedPositionProfile,
isLoggedIn,
rememberLoginRedirect,
saveSelectedPosition,
userPositionOptions,
type UserPosition
} from '@/services/auth';
import { getCompetencyProfile, getPracticeHistory, getPromotionEvidence } from '@/services/practice';
import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
const loggedIn = ref(false);
const phone = ref('');
const loading = ref(false);
const message = ref('');
const profile = ref<CompetencyProfile | null>(null);
const history = ref<PracticeRecord[]>([]);
const evidence = ref<PromotionEvidence | null>(null);
const positionProfile = ref(getSelectedPositionProfile());
const positionCards = userPositionOptions.map((item) => getPositionProfile(item));
const refreshAuth = () => {
const auth = getAuth();
loggedIn.value = isLoggedIn();
phone.value = auth.phone;
positionProfile.value = getSelectedPositionProfile();
};
const clearProfileData = () => {
profile.value = null;
history.value = [];
evidence.value = null;
};
const loadData = async () => {
refreshAuth();
if (!loggedIn.value) {
clearProfileData();
return;
}
if (!(await ensureEmployeePosition('/pages/user/profile/index'))) {
return;
}
refreshAuth();
loading.value = true;
message.value = '';
try {
const [nextHistory, nextProfile, nextEvidence] = await Promise.all([
getPracticeHistory(),
getCompetencyProfile(),
getPromotionEvidence()
]);
history.value = nextHistory || [];
profile.value = nextProfile || null;
evidence.value = nextEvidence || null;
} catch (error) {
refreshAuth();
if (!loggedIn.value) {
clearProfileData();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : '我的数据加载失败';
} finally {
loading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/profile/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const logout = () => {
clearAuth();
clearProfileData();
refreshAuth();
uni.showToast({ title: '已退出', icon: 'none' });
};
const changePosition = (position: UserPosition) => {
const saved = saveSelectedPosition(position);
if (!saved) return;
positionProfile.value = getSelectedPositionProfile();
uni.showToast({ title: '岗位已更新', icon: 'none' });
};
const openHistory = (id?: number) => {
if (!id) {
message.value = '这条训练记录暂不支持查看详情';
return;
}
uni.navigateTo({ url: `/pages/user/history/index?id=${id}` });
};
onShow(() => {
resetPageScroll();
void loadData();
});
</script>
<style scoped>
.profile-page {
padding: 0 16px 108px;
}
.profile-page .panel {
border-radius: var(--employee-card-radius);
box-shadow: var(--employee-card-shadow);
}
.profile-head {
position: relative;
margin: 0 -16px;
padding: 30px 20px 26px;
border-bottom: 1px solid rgba(47, 74, 117, 0.08);
background: linear-gradient(180deg, #f8fbff 0%, #edf4ff 100%);
}
.profile-head::after {
position: absolute;
right: 20px;
bottom: 20px;
width: 42px;
height: 4px;
border-radius: 4px;
background: var(--employee-primary);
content: '';
}
.profile-eyebrow {
display: block;
margin-bottom: 10px;
color: var(--employee-primary);
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1.2;
}
.profile-head .page-title {
color: var(--employee-text);
font-size: 28px;
line-height: 1.2;
}
.profile-head .page-subtitle {
margin-top: 10px;
color: var(--employee-muted);
font-size: 14px;
}
.account-card,
.history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.account-card {
margin-top: 16px;
border-left: 4px solid var(--employee-primary);
}
.account-phone {
display: block;
margin-top: 8px;
color: var(--employee-muted);
font-size: 14px;
}
.account-action {
min-width: 84px;
padding: 0 12px;
}
.position-current {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.position-title {
display: block;
margin-top: 8px;
color: var(--employee-primary);
font-size: 16px;
font-weight: 900;
}
.position-actions {
display: grid;
margin-top: 14px;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.position-chip {
display: flex;
min-height: 38px;
align-items: center;
justify-content: center;
border: 1px solid #dce5f2;
border-radius: var(--employee-chip-radius);
background: #f8fbff;
color: #526176;
font-size: 13px;
font-weight: 900;
white-space: nowrap;
}
.position-chip.active {
border-color: var(--employee-primary);
background: var(--employee-primary-soft);
color: var(--employee-primary);
}
.profile-summary {
border-color: var(--employee-line);
border-top: 3px solid var(--employee-primary);
}
.score-row {
display: grid;
margin-top: 16px;
grid-template-columns: 104px 1fr;
gap: 14px;
}
.ai-score-disclaimer {
display: block;
margin-top: 8px;
color: var(--employee-muted);
font-size: 12px;
line-height: 1.45;
}
.score-main {
display: flex;
min-height: 124px;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 18px;
background: var(--employee-primary-soft);
}
.score-value {
color: var(--employee-primary);
font-size: 42px;
font-weight: 900;
line-height: 1;
}
.score-label {
margin-top: 8px;
color: var(--employee-muted);
font-size: 13px;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.metric {
display: flex;
min-height: 58px;
flex-direction: column;
justify-content: center;
padding: 8px 10px;
border: 1px solid var(--employee-line);
border-radius: 14px;
background: #fbfdff;
}
.metric text:first-child {
color: var(--employee-text);
font-size: 18px;
font-weight: 900;
}
.metric text:last-child {
margin-top: 4px;
color: var(--employee-muted);
font-size: 12px;
}
.evidence-panel {
border-color: #f4e4c4;
border-left: 4px solid var(--employee-warning);
background: #fffaf2;
}
.evidence-grid {
display: grid;
margin-top: 14px;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.evidence-metric {
display: flex;
min-height: 58px;
flex-direction: column;
justify-content: center;
padding: 8px 10px;
border: 1px solid #f4e7ce;
border-radius: 14px;
background: #ffffff;
}
.evidence-metric text:first-child {
color: #b56b14;
font-size: 18px;
font-weight: 900;
}
.evidence-metric text:last-child {
margin-top: 4px;
color: var(--employee-muted);
font-size: 12px;
}
.evidence-list {
margin-top: 12px;
}
.evidence-item {
display: block;
padding: 9px 0;
border-top: 1px solid #efe1bb;
color: #775000;
font-size: 13px;
line-height: 1.45;
}
.dimension-list,
.history-list {
margin-top: 14px;
}
.growth-section {
margin-top: 18px;
}
.section-label {
display: block;
color: #34373c;
font-size: 14px;
font-weight: 900;
}
.growth-row {
display: grid;
grid-template-columns: 1fr 96px;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #e7e7e9;
}
.growth-row:first-of-type {
margin-top: 10px;
}
.growth-title,
.growth-desc {
display: block;
}
.growth-title {
color: var(--employee-text);
font-size: 14px;
font-weight: 900;
}
.growth-desc {
margin-top: 5px;
color: var(--employee-muted);
font-size: 12px;
line-height: 1.4;
}
.growth-progress {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 6px;
color: var(--employee-primary);
font-size: 12px;
font-weight: 900;
}
.growth-bar {
width: 88px;
height: 6px;
overflow: hidden;
border-radius: 4px;
background: #dce8fb;
}
.growth-fill {
height: 100%;
border-radius: inherit;
background: var(--employee-primary);
}
.dimension-row,
.history-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-top: 1px solid #e7e7e9;
}
.dimension-title,
.history-title {
display: block;
color: var(--employee-text);
font-weight: 900;
}
.dimension-note,
.history-desc {
display: block;
margin-top: 5px;
color: var(--employee-muted);
font-size: 13px;
line-height: 1.4;
}
.dimension-score {
color: var(--employee-primary);
font-size: 20px;
font-weight: 900;
}
.dimension-score.pending {
color: var(--employee-muted);
font-size: 14px;
}
.refresh-button {
min-height: 36px;
padding: 0 12px;
border: 1px solid #dce5f2;
border-radius: var(--employee-chip-radius);
background: #ffffff;
color: #526176;
font-size: 13px;
}
.history-panel {
border-color: var(--employee-line);
}
.history-row-clickable {
min-height: 68px;
padding: 14px 4px;
cursor: pointer;
}
.history-row-clickable:active {
opacity: 0.72;
}
.history-score {
display: flex;
min-width: 52px;
flex-direction: column;
align-items: flex-end;
}
.history-score text:first-child {
color: var(--employee-primary);
font-size: 20px;
font-weight: 900;
}
.history-score text:last-child {
margin-top: 4px;
color: var(--employee-muted);
font-size: 12px;
}
</style>