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>
This commit is contained in:
2026-07-18 12:38:14 +08:00
co-authored by Claude Fable 5
parent d5ea4b5451
commit 0a7eb38e05
7 changed files with 983 additions and 2 deletions
+6
View File
@@ -79,6 +79,12 @@
"navigationBarTitleText": "我的积分"
}
},
{
"path": "pages/user/report/index",
"style": {
"navigationBarTitleText": "工作上报"
}
},
{
"path": "pages/user/exam/index",
"style": {
+2 -2
View File
@@ -145,7 +145,7 @@
<view class="feature-entry-icon report">报</view>
<view class="feature-entry-copy">
<text class="feature-entry-title">工作上报</text>
<text class="feature-entry-desc">日报 / 任务 / 问题上报 · 建设中</text>
<text class="feature-entry-desc">上报案例 / 视频 / SOP / 知识</text>
</view>
<text class="points-entry-arrow">›</text>
</button>
@@ -298,7 +298,7 @@ const openPracticeSummary = () => {
};
const openReport = () => {
uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent('工作上报')}` });
uni.navigateTo({ url: '/pages/user/report/index' });
};
const clearProfileData = () => {
+527
View File
@@ -0,0 +1,527 @@
<template>
<view class="page report-page employee-page-shell">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回" @click="goBack">←</button>
<view class="subpage-heading">
<text class="employee-kicker">经验沉淀 · 审核后入库</text>
<text class="page-title">工作上报</text>
<text class="page-subtitle">把现场的好经验报上来,审核通过后沉淀进企业知识。</text>
</view>
</view>
<view v-if="!loggedIn" class="panel employee-card login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后才能提交上报并查看审核进度。</text>
</view>
<button class="primary-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel employee-card type-panel">
<text class="panel-title">选择上报类型</text>
<view class="type-grid">
<button
v-for="item in workReportTypes"
:key="item.value"
class="type-tile"
:class="{ active: form.type === item.value }"
@click="form.type = item.value"
>
<text class="type-icon">{{ item.icon }}</text>
<text class="type-label">{{ item.label }}</text>
<text class="type-desc">{{ item.desc }}</text>
</button>
</view>
</view>
<view class="panel employee-card form-panel">
<text class="field-label">标题</text>
<input v-model="form.title" class="input" maxlength="100" placeholder="一句话说清上报内容" />
<text class="field-label">说明</text>
<textarea
v-model="form.content"
class="textarea report-textarea"
maxlength="2000"
:placeholder="contentPlaceholder"
/>
<view class="voice-row">
<button
class="secondary-button compact-button"
:class="{ 'recording-active': recording }"
:disabled="transcribing || submitting"
@click="toggleRecording"
>
{{ recording ? '停止录音' : transcribing ? '转写中' : '语音转写' }}
</button>
<text class="voice-hint">说一段话自动转成文字,可修改后提交</text>
</view>
<text class="field-label">附件{{ form.type === 'VIDEO' ? '(视频,必传)' : '(可选)' }}</text>
<view v-if="attachment" class="media-file">
<text class="media-name">{{ attachment.fileName }}</text>
<button class="media-remove" :disabled="submitting" aria-label="移除附件" @click="attachment = null">×</button>
</view>
<button v-else class="ghost-button" :disabled="uploading || submitting" @click="pickAttachment">
{{ uploading ? '上传中' : '+ 选择文件' }}
</button>
<text class="muted attach-hint">图片≤10MB,视频≤100MB;音频/文档也可作为附件</text>
<button class="primary-button submit-button" :disabled="submitting || uploading" @click="submit">
{{ submitting ? '提交中' : '提交审核' }}
</button>
<text v-if="message" class="status-message">{{ message }}</text>
</view>
<view class="panel employee-card mine-panel">
<view class="panel-head-row">
<text class="panel-title">我的上报</text>
<button class="ghost-button compact-button" :disabled="loadingMine" @click="loadMine">刷新</button>
</view>
<view v-if="myReports.length" class="report-list">
<view v-for="item in myReports" :key="item.id" class="report-row">
<view class="report-copy">
<view class="report-meta">
<text class="report-type">{{ item.typeLabel }}</text>
<text class="report-status" :class="item.status.toLowerCase()">{{ item.statusLabel }}</text>
</view>
<text class="report-title">{{ item.title }}</text>
<text class="report-desc">{{ item.createdAt }}{{ item.attachmentName ? ` · 📎 ${item.attachmentName}` : '' }}</text>
<text v-if="item.reviewNote" class="report-note">审核意见:{{ item.reviewNote }}</text>
</view>
</view>
</view>
<view v-else-if="!loadingMine" class="empty">还没有上报记录,报一条试试</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { chooseKnowledgeMedia } from '@/services/knowledge';
import { transcribeSpeechBlob } from '@/services/speech';
import {
listMyWorkReports,
submitWorkReport,
uploadWorkReportAttachment,
workReportTypes,
type WorkReportAttachment,
type WorkReportRecord,
type WorkReportType
} from '@/services/work-report';
const path = '/pages/user/report/index';
const loggedIn = ref(false);
const form = ref<{ type: WorkReportType; title: string; content: string }>({ type: 'CASE', title: '', content: '' });
const attachment = ref<WorkReportAttachment | null>(null);
const myReports = ref<WorkReportRecord[]>([]);
const message = ref('');
const submitting = ref(false);
const uploading = ref(false);
const loadingMine = ref(false);
const recording = ref(false);
const transcribing = ref(false);
let mediaRecorder: MediaRecorder | null = null;
let mediaStream: MediaStream | null = null;
let transcribeGeneration = 0;
const contentPlaceholder = computed(() => {
if (form.value.type === 'CASE') return '写清楚:什么场景、你怎么处理的、结果如何。';
if (form.value.type === 'VIDEO') return '说明视频里演示的是什么操作、适用什么场景。';
if (form.value.type === 'SOP') return '贴出完整流程步骤,或说明附件里 SOP 的适用范围。';
return '写下你觉得对同事有帮助的经验或知识点。';
});
const goBack = () => uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/user/profile/index' }) });
const goLogin = () => {
rememberLoginRedirect(path);
uni.navigateTo({ url: '/pages/auth/login/index' });
};
const loadMine = async () => {
if (!loggedIn.value || loadingMine.value) return;
loadingMine.value = true;
try {
myReports.value = await listMyWorkReports();
} catch (error) {
message.value = error instanceof Error ? error.message : '上报记录加载失败';
} finally {
loadingMine.value = false;
}
};
const pickAttachment = async () => {
if (uploading.value) return;
try {
const media = await chooseKnowledgeMedia();
uploading.value = true;
attachment.value = await uploadWorkReportAttachment(media.path);
message.value = '';
} catch (error) {
const text = error instanceof Error ? error.message : '附件选择失败';
if (text !== '未选择附件') message.value = text;
} finally {
uploading.value = false;
}
};
const stopTracks = () => {
mediaStream?.getTracks().forEach((track) => track.stop());
mediaStream = null;
};
const toggleRecording = async () => {
if (transcribing.value) return;
if (recording.value) {
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
return;
}
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
message.value = '当前环境不支持录音,请直接输入文字';
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaStream = stream;
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
mediaRecorder = recorder;
recorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
recorder.onstop = () => {
recording.value = false;
mediaRecorder = null;
stopTracks();
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
if (blob.size) void transcribe(blob);
};
recorder.start();
recording.value = true;
message.value = '正在录音,说完再点一次停止';
} catch {
stopTracks();
message.value = '麦克风不可用,请直接输入文字';
}
};
const transcribe = async (blob: Blob) => {
const generation = ++transcribeGeneration;
transcribing.value = true;
message.value = '语音转写中';
try {
const result = await transcribeSpeechBlob(blob, 'work-report.webm');
if (generation !== transcribeGeneration) return;
const text = (result.text || '').trim();
if (!text) throw new Error('语音没有识别到文字');
const current = form.value.content.trim();
form.value.content = current ? `${current}\n${text}` : text;
message.value = '语音已转成文字,请确认后提交';
} catch (error) {
if (generation !== transcribeGeneration) return;
message.value = error instanceof Error ? error.message : '语音转写失败,请直接输入文字';
} finally {
if (generation === transcribeGeneration) transcribing.value = false;
}
};
const cancelRecording = () => {
transcribeGeneration += 1;
transcribing.value = false;
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.ondataavailable = null;
mediaRecorder.onstop = null;
mediaRecorder.stop();
}
mediaRecorder = null;
stopTracks();
recording.value = false;
};
const submit = async () => {
if (submitting.value) return;
const title = form.value.title.trim();
const content = form.value.content.trim();
if (!title) {
message.value = '请填写标题';
return;
}
if (!content) {
message.value = '请填写说明';
return;
}
if (form.value.type === 'VIDEO' && !attachment.value) {
message.value = '操作视频上报需要上传视频附件';
return;
}
submitting.value = true;
message.value = '';
try {
await submitWorkReport({
type: form.value.type,
title,
content,
attachmentOssId: attachment.value?.ossId,
attachmentName: attachment.value?.fileName
});
form.value.title = '';
form.value.content = '';
attachment.value = null;
uni.showToast({ title: '已提交,等待审核', icon: 'none' });
await loadMine();
} catch (error) {
message.value = error instanceof Error ? error.message : '提交失败,请稍后重试';
} finally {
submitting.value = false;
}
};
onShow(() => {
resetPageScroll();
loggedIn.value = isLoggedIn();
if (loggedIn.value) void loadMine();
});
onHide(cancelRecording);
onUnload(cancelRecording);
</script>
<style scoped>
.report-page {
max-width: var(--mobile-shell-max-width);
margin: 0 auto;
}
.login-panel {
display: grid;
gap: 14px;
}
.block {
display: block;
margin-top: 6px;
}
.type-panel {
display: grid;
gap: 12px;
}
.type-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.type-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
margin: 0;
padding: 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: #ffffff;
text-align: left;
line-height: 1.35;
}
.type-tile.active {
border-color: var(--brand);
background: var(--employee-primary-soft);
}
.type-icon {
font-size: 20px;
}
.type-label {
color: var(--ink);
font-size: 15px;
font-weight: 800;
}
.type-desc {
color: var(--muted);
font-size: 11px;
}
.form-panel {
display: grid;
gap: 10px;
}
.field-label {
color: var(--ink);
font-size: 14px;
font-weight: 700;
}
.input {
box-sizing: border-box;
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
font-size: 15px;
}
.report-textarea {
min-height: 120px;
}
.voice-row {
display: flex;
align-items: center;
gap: 10px;
}
.voice-hint {
color: var(--muted);
font-size: 12px;
}
.media-file {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
}
.media-name {
flex: 1;
overflow: hidden;
color: var(--ink);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-remove {
flex-shrink: 0;
width: 26px;
height: 26px;
margin: 0;
padding: 0;
border-radius: 50%;
background: #eef0f3;
color: #6b7480;
font-size: 15px;
line-height: 26px;
}
.attach-hint {
font-size: 12px;
}
.submit-button {
width: 100%;
margin-top: 4px;
}
.status-message {
display: block;
padding: 10px 12px;
border-radius: 8px;
background: #fff0f1;
color: #a9000d;
font-size: 13px;
line-height: 1.5;
}
.mine-panel {
display: grid;
gap: 12px;
}
.panel-head-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.report-list {
display: grid;
gap: 10px;
}
.report-row {
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #ffffff;
}
.report-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.report-type {
padding: 2px 8px;
border-radius: 6px;
background: var(--employee-primary-soft);
color: var(--brand);
font-size: 11px;
font-weight: 700;
}
.report-status {
font-size: 11px;
font-weight: 700;
}
.report-status.pending {
color: #d98200;
}
.report-status.approved {
color: #168243;
}
.report-status.rejected {
color: #bd0010;
}
.report-title {
display: block;
color: var(--ink);
font-size: 14px;
font-weight: 700;
}
.report-desc {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.report-note {
display: block;
margin-top: 5px;
color: #78500a;
font-size: 12px;
}
.empty {
color: var(--muted);
font-size: 13px;
text-align: center;
padding: 12px 0;
}
</style>
+86
View File
@@ -0,0 +1,86 @@
import type { ApiPayload } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
export type WorkReportType = 'CASE' | 'VIDEO' | 'SOP' | 'KNOWLEDGE';
export interface WorkReportRecord {
id: number;
type: WorkReportType;
typeLabel: string;
title: string;
content: string;
attachmentName?: string;
attachmentOssId?: number | string;
status: 'PENDING' | 'APPROVED' | 'REJECTED';
statusLabel: string;
submitterName: string;
reviewNote?: string;
createdAt: string;
reviewedAt?: string;
}
export interface WorkReportAttachment {
ossId: number | string;
fileName: string;
}
export const workReportTypes: Array<{ value: WorkReportType; label: string; desc: string; icon: string }> = [
{ value: 'CASE', label: '优秀案例', desc: '现场处理得好的真实案例', icon: '⭐' },
{ value: 'VIDEO', label: '操作视频', desc: '值得推广的操作演示视频', icon: '🎬' },
{ value: 'SOP', label: '完整SOP', desc: '整理好的完整工作流程', icon: '📋' },
{ value: 'KNOWLEDGE', label: '有用知识', desc: '对工作有帮助的经验知识', icon: '💡' }
];
export const submitWorkReport = (params: {
type: WorkReportType;
title: string;
content: string;
attachmentOssId?: number | string;
attachmentName?: string;
}) =>
apiRequest<WorkReportRecord>({
url: '/api/aihr/work-report/reports',
method: 'POST',
data: params,
timeout: 20000
});
export const listMyWorkReports = () =>
apiRequest<WorkReportRecord[]>({
url: '/api/aihr/work-report/reports/mine',
method: 'GET',
timeout: 15000
});
export const listReviewWorkReports = (status = '') =>
apiRequest<WorkReportRecord[]>({
url: `/api/aihr/work-report/reports${status ? `?status=${encodeURIComponent(status)}` : ''}`,
method: 'GET',
timeout: 15000
});
export const reviewWorkReport = (id: number, decision: 'APPROVE' | 'REJECT', note = '') =>
apiRequest<WorkReportRecord>({
url: `/api/aihr/work-report/reports/${id}/review`,
method: 'POST',
data: { decision, note },
timeout: 20000
});
export const uploadWorkReportAttachment = (filePath: string) =>
new Promise<WorkReportAttachment>((resolve, reject) => {
uni.uploadFile({
url: apiUrl('/api/aihr/work-report/attachment'),
filePath,
name: 'file',
header: authHeaders(false),
success: (response) => {
try {
resolve(readTextPayload<WorkReportAttachment>(response.statusCode, response.data));
} catch (error) {
reject(error);
}
},
fail: () => reject(new Error('附件上传失败'))
});
});