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
@@ -0,0 +1,70 @@
package org.dromara.aihr.report;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.report.AihrWorkReportDto.AttachmentResponse;
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest;
import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
import org.dromara.common.core.domain.R;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 工作上报:员工提交优秀案例/操作视频/完整SOP/有用知识,主管或运营审核。
*/
@RestController
@RequiredArgsConstructor
@SaCheckLogin
@RequestMapping("/api/aihr/work-report")
public class AihrWorkReportController {
private static final long MAX_ATTACHMENT_BYTES = 100L * 1024 * 1024;
private final AihrWorkReportService reportService;
private final ISysOssService ossService;
@PostMapping("/reports")
public R<ReportResponse> create(@RequestBody CreateReportRequest request) {
return R.ok(reportService.create(request));
}
@PostMapping(value = "/attachment", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<AttachmentResponse> uploadAttachment(@RequestPart("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
return R.fail("请选择附件文件");
}
if (file.getSize() > MAX_ATTACHMENT_BYTES) {
return R.fail("附件不能超过 100MB");
}
SysOssVo oss = ossService.upload(file);
return R.ok(new AttachmentResponse(oss.getOssId(), file.getOriginalFilename()));
}
@GetMapping("/reports/mine")
public R<List<ReportResponse>> mine() {
return R.ok(reportService.myReports());
}
@GetMapping("/reports")
public R<List<ReportResponse>> list(@RequestParam(value = "status", required = false) String status) {
return R.ok(reportService.pendingReports(status));
}
@PostMapping("/reports/{id}/review")
public R<ReportResponse> review(@PathVariable Long id, @RequestBody ReviewReportRequest request) {
return R.ok(reportService.review(id, request));
}
}
@@ -0,0 +1,44 @@
package org.dromara.aihr.report;
import java.util.List;
/**
* 工作上报 DTO:员工提交四类上报(案例/操作视频/SOP/知识),主管或运营审核。
* 审核通过后由运营在对应库(案例库/知识库)完成最终入库,上报模块只管收集与审核。
*/
public final class AihrWorkReportDto {
private AihrWorkReportDto() {
}
public record CreateReportRequest(String type,
String title,
String content,
Long attachmentOssId,
String attachmentName) {
}
public record ReviewReportRequest(String decision, String note) {
}
public record AttachmentResponse(Long ossId, String fileName) {
}
public record ReportResponse(Long id,
String type,
String typeLabel,
String title,
String content,
String attachmentName,
Long attachmentOssId,
String status,
String statusLabel,
String submitterName,
String reviewNote,
String createdAt,
String reviewedAt) {
}
public record ReportListResponse(List<ReportResponse> records) {
}
}
@@ -0,0 +1,248 @@
package org.dromara.aihr.report;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.community.AihrCommunityPolicy;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.report.AihrWorkReportDto.CreateReportRequest;
import org.dromara.aihr.report.AihrWorkReportDto.ReportResponse;
import org.dromara.aihr.report.AihrWorkReportDto.ReviewReportRequest;
import org.dromara.common.core.exception.ServiceException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 工作上报:员工提交 → 主管/运营审核(通过/驳回)。
* 通过后的入库动作(案例库/知识库)由运营在对应模块人工完成,不自动入库。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AihrWorkReportService {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final Map<String, String> TYPE_LABELS = Map.of(
"CASE", "优秀案例",
"VIDEO", "操作视频",
"SOP", "完整SOP",
"KNOWLEDGE", "有用知识"
);
private static final Map<String, String> STATUS_LABELS = Map.of(
"PENDING", "待审核",
"APPROVED", "已通过",
"REJECTED", "已驳回"
);
private static final Set<String> REVIEW_DECISIONS = Set.of("APPROVE", "REJECT");
private static final int MAX_TITLE = 100;
private static final int MAX_CONTENT = 2000;
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgePrincipalResolver principalResolver;
private volatile boolean tableReady;
public ReportResponse create(CreateReportRequest request) {
ensureTable();
AihrKnowledgePrincipal principal = principalResolver.current();
String type = clean(request == null ? null : request.type()).toUpperCase();
if (!TYPE_LABELS.containsKey(type)) {
throw new ServiceException("上报类型无效,仅支持:优秀案例/操作视频/完整SOP/有用知识", 400);
}
String title = clean(request.title());
if (title.isBlank()) {
throw new ServiceException("请填写上报标题", 400);
}
String content = clean(request.content());
if (content.isBlank()) {
throw new ServiceException("请填写上报说明", 400);
}
LocalDateTime now = LocalDateTime.now();
jdbcTemplate.update("""
INSERT INTO aihr_work_report
(tenant_id, report_type, title, content, attachment_oss_id, attachment_name,
status, submitter_user_id, submitter_identity, submitter_name, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, ?, ?)
""",
principal.tenantId(),
type,
truncate(title, MAX_TITLE),
truncate(content, MAX_CONTENT),
request.attachmentOssId(),
truncate(clean(request.attachmentName()), 200),
principal.userId(),
clean(principal.extPartyId()),
displayName(principal),
Timestamp.valueOf(now),
Timestamp.valueOf(now)
);
Long id = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
return byId(id, principal.tenantId());
}
public List<ReportResponse> myReports() {
ensureTable();
AihrKnowledgePrincipal principal = principalResolver.current();
return jdbcTemplate.query(SELECT_SQL + """
WHERE tenant_id = ? AND submitter_user_id = ?
ORDER BY id DESC
LIMIT 50
""", this::mapRow, principal.tenantId(), principal.userId());
}
public List<ReportResponse> pendingReports(String status) {
ensureTable();
AihrKnowledgePrincipal principal = requireModerator();
String filter = clean(status).toUpperCase();
if (!filter.isBlank() && !STATUS_LABELS.containsKey(filter)) {
filter = "";
}
if (filter.isBlank()) {
return jdbcTemplate.query(SELECT_SQL + """
WHERE tenant_id = ?
ORDER BY CASE status WHEN 'PENDING' THEN 0 ELSE 1 END, id DESC
LIMIT 100
""", this::mapRow, principal.tenantId());
}
return jdbcTemplate.query(SELECT_SQL + """
WHERE tenant_id = ? AND status = ?
ORDER BY id DESC
LIMIT 100
""", this::mapRow, principal.tenantId(), filter);
}
public ReportResponse review(Long id, ReviewReportRequest request) {
ensureTable();
AihrKnowledgePrincipal principal = requireModerator();
String decision = clean(request == null ? null : request.decision()).toUpperCase();
if (!REVIEW_DECISIONS.contains(decision)) {
throw new ServiceException("审核结论无效,仅支持 APPROVE / REJECT", 400);
}
String nextStatus = "APPROVE".equals(decision) ? "APPROVED" : "REJECTED";
int updated = jdbcTemplate.update("""
UPDATE aihr_work_report
SET status = ?, review_note = ?, reviewer_user_id = ?, reviewer_name = ?,
review_time = ?, update_time = ?
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'
""",
nextStatus,
truncate(clean(request.note()), 500),
principal.userId(),
displayName(principal),
Timestamp.valueOf(LocalDateTime.now()),
Timestamp.valueOf(LocalDateTime.now()),
principal.tenantId(),
id
);
if (updated == 0) {
throw new ServiceException("该上报不存在或已审核", 400);
}
return byId(id, principal.tenantId());
}
private ReportResponse byId(Long id, String tenantId) {
List<ReportResponse> rows = jdbcTemplate.query(SELECT_SQL + " WHERE tenant_id = ? AND id = ?",
this::mapRow, tenantId, id);
if (rows.isEmpty()) {
throw new ServiceException("上报记录不存在", 404);
}
return rows.get(0);
}
private static final String SELECT_SQL = """
SELECT id, report_type, title, content, attachment_oss_id, attachment_name,
status, submitter_name, review_note, create_time, review_time
FROM aihr_work_report
""";
private ReportResponse mapRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
String type = rs.getString("report_type");
String status = rs.getString("status");
return new ReportResponse(
rs.getLong("id"),
type,
TYPE_LABELS.getOrDefault(type, type),
rs.getString("title"),
rs.getString("content"),
rs.getString("attachment_name"),
rs.getObject("attachment_oss_id", Long.class),
status,
STATUS_LABELS.getOrDefault(status, status),
rs.getString("submitter_name"),
rs.getString("review_note"),
time(rs.getTimestamp("create_time")),
time(rs.getTimestamp("review_time"))
);
}
private AihrKnowledgePrincipal requireModerator() {
AihrKnowledgePrincipal principal = principalResolver.current();
if (!AihrCommunityPolicy.canModerate(principal)) {
throw new ServiceException("仅主管或管理员可执行该操作", 403);
}
return principal;
}
private void ensureTable() {
if (tableReady) {
return;
}
synchronized (this) {
if (tableReady) {
return;
}
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_work_report` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '000000' COMMENT '租户编号',
`report_type` varchar(20) COLLATE utf8mb4_general_ci NOT NULL COMMENT '类型 CASE/VIDEO/SOP/KNOWLEDGE',
`title` varchar(100) COLLATE utf8mb4_general_ci NOT NULL COMMENT '标题',
`content` varchar(2000) COLLATE utf8mb4_general_ci NOT NULL COMMENT '说明',
`attachment_oss_id` bigint DEFAULT NULL COMMENT '附件OSS编号',
`attachment_name` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '附件名',
`status` varchar(20) COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'PENDING' COMMENT '状态 PENDING/APPROVED/REJECTED',
`submitter_user_id` bigint NOT NULL COMMENT '提交人用户ID',
`submitter_identity` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提交人身份',
`submitter_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '提交人显示名',
`reviewer_user_id` bigint DEFAULT NULL COMMENT '审核人用户ID',
`reviewer_name` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核人显示名',
`review_note` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核意见',
`review_time` datetime DEFAULT NULL COMMENT '审核时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_work_report_submitter` (`tenant_id`, `submitter_user_id`, `id`),
KEY `idx_aihr_work_report_status` (`tenant_id`, `status`, `id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '工作上报'
""");
tableReady = true;
}
}
private static String displayName(AihrKnowledgePrincipal principal) {
String identity = clean(principal.extPartyId());
if (identity.isBlank()) {
return "管理员" + principal.userId();
}
return identity.length() <= 4 ? identity : "员工" + identity.substring(identity.length() - 4);
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private static String truncate(String value, int max) {
return value.length() <= max ? value : value.substring(0, max);
}
private static String time(Timestamp value) {
return value == null ? "" : value.toLocalDateTime().format(TIME_FORMAT);
}
}
+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('附件上传失败'))
});
});