647 lines
15 KiB
Vue
647 lines
15 KiB
Vue
<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>
|