Files
prop-ai-hr/mobile-uni/src/pages/user/profile/index.vue
T
adminandClaude Fable 5 0a7eb38e05 feat: work-report module V1 — four report types with review flow
客户 2026-07-18 语音需求落地:「报」分四种——优秀案例/操作视频/
完整SOP/有用知识,员工提交、主管或运营审核。

后端(新包 org.dromara.aihr.report):
- aihr_work_report 表(自建,utf8mb4_general_ci 显式声明,含提交人/
  审核人/状态/附件 OSS 编号,提交人与状态双索引)
- POST /api/aihr/work-report/reports 员工提交(类型/标题/说明校验)
- POST /attachment 附件上传(≤100MB,OSS 落盘,ossId 字符串安全)
- GET /reports/mine 我的上报;GET /reports 审核队列(canModerate 门禁,
  复用社区 Policy);POST /{id}/review APPROVE/REJECT
  (仅 PENDING 可审,重复审核 400)
- 审核通过后的入库动作留给运营在案例库/知识库人工完成(不自动入库,
  遵守'入知识库必须人工确认'原则)

员工端:
- 新页 pages/user/report(替换'建设中'占位):四类型卡片选择、
  标题+说明(说明支持录音转写)、附件选择上传(视频类必传校验)、
  我的上报列表(待审核/已通过/已驳回+审核意见)
- 「我」页常用功能入口文案更新

生产验证(端到端):
- API:员工提交→我的上报可见→员工查审核队列403→主管队列可见
  →审核通过→重复审核400 全通过
- UI:我页入口→上报页→选类型→填表→提交→列表显示'待审核',
  历史记录显示'已通过'+审核意见
- 后端发布(备份 ruoyi-admin.jar.bak-20260718123032,25s恢复),
  H5 已发布(备份 h5-before-20260718123032)

二期待办:主管端待审列表页(现主管可用接口审核)、通过后一键转
案例库/知识库、上报采纳积分奖励(等客户定规则)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 12:38:14 +08:00

931 lines
23 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>
<text class="position-source">岗位来自人力系统组织同步,如需调整请联系 HR。</text>
</view>
</view>
</view>
<view v-if="supervisorLearning" class="panel supervisor-mode-panel">
<view>
<text class="panel-title">当前为学员模式</text>
<text class="position-source">主管组织身份未改变,仅切换到个人学习与训练界面。</text>
</view>
<button class="secondary-button account-action" @click="returnToSupervisor">返回主管工作台</button>
</view>
<view class="panel display-panel">
<view class="display-heading">
<view>
<text class="panel-title">显示与辅助</text>
<text class="display-current">字体大小 · {{ currentFontSizeLabel }}</text>
</view>
</view>
<view class="font-size-options" role="group" aria-label="字体大小">
<button
v-for="option in appFontSizeOptions"
:key="option.value"
class="font-size-option"
:class="{ active: fontSize === option.value }"
:aria-pressed="fontSize === option.value"
@click="changeFontSize(option.value)"
>
{{ option.label }}
</button>
</view>
<text class="display-note">选择后会同步到今日、练、问、我等全部页面,并保留你的设置。</text>
</view>
<view v-if="loggedIn && profile" class="panel profile-summary">
<text class="panel-title">成长摘要</text>
<text class="ai-score-disclaimer">AI训练综合分仅作训练参考,不等于BRD五维绩效总分,人可复核/否决</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>
<button class="fold-toggle" @click="showGrowthDetail = !showGrowthDetail">
{{ showGrowthDetail ? '收起能力详情 ▲' : '展开能力详情(五维评分 · 成长路径)▼' }}
</button>
<view v-if="showGrowthDetail && 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="showGrowthDetail && 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>
<button v-if="loggedIn" class="panel points-entry" @click="openPoints">
<view>
<text class="panel-title">我的积分</text>
<text class="points-entry-desc">查看问题榜奖励积分与学习学分明细</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
<view v-if="loggedIn" class="panel feature-entries">
<text class="panel-title">常用功能</text>
<button class="feature-entry" @click="openLearning">
<view class="feature-entry-icon learn">学</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">学习中心</text>
<text class="feature-entry-desc">课程学习 · 错题本 · 岗位 SOP</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
<button class="feature-entry" @click="openPracticeSummary">
<view class="feature-entry-icon summary">训</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">我的训练概况</text>
<text class="feature-entry-desc">问师傅帮你汇总近期训练情况</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
<button class="feature-entry" @click="openReport">
<view class="feature-entry-icon report">报</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">工作上报</text>
<text class="feature-entry-desc">上报案例 / 视频 / SOP / 知识</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
</view>
<view v-if="loggedIn && evidence" class="panel evidence-panel">
<view class="fold-head">
<text class="panel-title">成长证据包(待人工复核)</text>
<button class="fold-toggle inline" @click="showEvidence = !showEvidence">
{{ showEvidence ? '收起 ▲' : '展开 ▼' }}
</button>
</view>
<template v-if="showEvidence">
<view class="evidence-grid">
<view class="evidence-metric">
<text>{{ evidence.currentLevel }}</text>
<text>参考等级</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.assessmentScore }}</text>
<text>AI测评分</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.humanAssessmentScore ?? '待采集' }}</text>
<text>人工校准分</text>
</view>
<view class="evidence-metric">
<text>{{ evidence.finalAdoptedScore ?? 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>
</template>
</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 visibleHistory"
: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>
<button v-if="history.length > 3" class="fold-toggle" @click="showAllHistory = !showAllHistory">
{{ showAllHistory ? '收起 ▲' : `展开全部 ${history.length} 条 ▼` }}
</button>
</view>
<view v-else class="empty">暂无训练记录</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
import {
clearAuth,
getAuth,
getSelectedPositionProfile,
isLoggedIn,
isSupervisorLearnerMode,
leaveSupervisorLearnerMode,
rememberLoginRedirect
} from '@/services/auth';
import {
appFontSizeOptions,
getAppFontSize,
setAppFontSize,
type AppFontSize
} from '@/services/accessibility';
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 showGrowthDetail = ref(false);
const showEvidence = ref(false);
const showAllHistory = ref(false);
const visibleHistory = computed(() => (showAllHistory.value ? history.value : history.value.slice(0, 3)));
const evidence = ref<PromotionEvidence | null>(null);
const positionProfile = ref(getSelectedPositionProfile());
const supervisorLearning = ref(false);
const fontSize = ref<AppFontSize>(getAppFontSize());
const currentFontSizeLabel = computed(() =>
appFontSizeOptions.find((item) => item.value === fontSize.value)?.label || '标准'
);
let profileLoadGeneration = 0;
let profileAccountPhone = '';
const refreshAuth = () => {
const auth = getAuth();
loggedIn.value = isLoggedIn();
phone.value = auth.phone;
positionProfile.value = getSelectedPositionProfile();
supervisorLearning.value = isSupervisorLearnerMode();
fontSize.value = getAppFontSize();
};
const changeFontSize = (value: AppFontSize) => {
fontSize.value = setAppFontSize(value);
uni.showToast({ title: `已切换为${currentFontSizeLabel.value}`, icon: 'none' });
};
const returnToSupervisor = () => {
leaveSupervisorLearnerMode();
supervisorLearning.value = false;
uni.redirectTo({ url: '/pages/supervisor/index/index' });
};
const openPoints = () => {
uni.navigateTo({ url: '/pages/user/points/index' });
};
const openLearning = () => {
uni.navigateTo({ url: '/pages/user/learning/index' });
};
const openPracticeSummary = () => {
uni.setStorageSync('aihr_sop_pending_tool', 'MY_PRACTICE_SUMMARY');
uni.switchTab({ url: '/pages/user/sop/index' });
};
const openReport = () => {
uni.navigateTo({ url: '/pages/user/report/index' });
};
const clearProfileData = () => {
profile.value = null;
history.value = [];
evidence.value = null;
profileAccountPhone = '';
};
const loadData = async () => {
const generation = ++profileLoadGeneration;
refreshAuth();
if (!loggedIn.value) {
clearProfileData();
return;
}
const requestedPhone = getAuth().phone;
if (profileAccountPhone && profileAccountPhone !== requestedPhone) {
clearProfileData();
}
if (!(await ensureEmployeePosition('/pages/user/profile/index'))) {
return;
}
refreshAuth();
if (generation !== profileLoadGeneration || !loggedIn.value) return;
const accountPhone = getAuth().phone;
loading.value = true;
message.value = '';
try {
const [nextHistory, nextProfile, nextEvidence] = await Promise.all([
getPracticeHistory(),
getCompetencyProfile(),
getPromotionEvidence()
]);
refreshAuth();
if (generation !== profileLoadGeneration) return;
if (!loggedIn.value || getAuth().phone !== accountPhone) {
clearProfileData();
return;
}
history.value = nextHistory || [];
profile.value = nextProfile || null;
evidence.value = nextEvidence || null;
profileAccountPhone = accountPhone;
} catch (error) {
refreshAuth();
if (generation !== profileLoadGeneration) return;
if (!loggedIn.value || getAuth().phone !== accountPhone) {
clearProfileData();
message.value = loggedIn.value ? '账号已切换,请刷新' : '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : '我的数据加载失败';
} finally {
if (generation === profileLoadGeneration) loading.value = false;
}
};
const goLogin = () => {
rememberLoginRedirect('/pages/user/profile/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const logout = () => {
profileLoadGeneration += 1;
loading.value = false;
clearAuth();
clearProfileData();
refreshAuth();
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,
.supervisor-mode-panel,
.display-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.display-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.display-current,
.display-note {
display: block;
margin-top: 7px;
color: var(--employee-muted);
font-size: 12px;
line-height: 1.5;
}
.font-size-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 4px;
border-radius: 14px;
background: #f0f3f8;
}
.font-size-option {
min-width: 0;
min-height: 42px;
padding: 0 2px;
border: 1px solid transparent;
border-radius: 11px;
background: transparent;
color: var(--employee-text);
font-size: 13px;
white-space: nowrap;
}
.font-size-option.active {
border-color: #f0c8cc;
background: #ffffff;
color: var(--employee-primary);
box-shadow: 0 4px 12px rgba(24, 34, 48, 0.08);
}
.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-source {
display: block;
margin-top: 7px;
color: var(--employee-muted);
font-size: 12px;
line-height: 1.5;
}
.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);
}
.points-entry {
display: flex;
width: calc(100% - 0px);
align-items: center;
justify-content: space-between;
color: var(--ink);
text-align: left;
}
.points-entry-desc {
display: block;
margin-top: 5px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.points-entry-arrow {
color: var(--muted);
font-size: 28px;
}
.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;
}
.feature-entries {
display: flex;
flex-direction: column;
gap: 4px;
}
.feature-entry {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
margin: 0;
padding: 12px 4px;
border: none;
background: transparent;
text-align: left;
line-height: 1.3;
}
.feature-entry::after {
border: none;
}
.feature-entry + .feature-entry {
border-top: 1px solid #f2f3f5;
}
.feature-entry-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
}
.feature-entry-icon.learn {
background: #1f5c9d;
}
.feature-entry-icon.summary {
background: #e70014;
}
.feature-entry-icon.report {
background: #d98200;
}
.feature-entry-copy {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.feature-entry-title {
color: #18202b;
font-size: 15px;
font-weight: 700;
}
.feature-entry-desc {
color: #8a93a0;
font-size: 12px;
}
.fold-toggle {
display: block;
width: 100%;
margin: 10px 0 0;
padding: 8px 0;
border: none;
border-top: 1px dashed #eceff2;
background: transparent;
color: #6b7480;
font-size: 13px;
text-align: center;
line-height: 1.5;
}
.fold-toggle::after {
border: none;
}
.fold-toggle.inline {
width: auto;
margin: 0;
padding: 2px 10px;
border: 1px solid #e3e6ea;
border-radius: 999px;
background: #f7f8fa;
font-size: 12px;
}
.fold-head {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>