feat(aihr): filter supervisor reviews by org scope

This commit is contained in:
2026-07-09 12:43:16 +08:00
parent 45d06f56cd
commit 9bd567a72f
8 changed files with 281 additions and 40 deletions
@@ -53,8 +53,10 @@ public class AihrMobileController {
} }
@GetMapping("/practice/reviews") @GetMapping("/practice/reviews")
public R<List<RecordResponse>> practiceReviews() { public R<List<RecordResponse>> practiceReviews(@RequestParam(required = false) String projectCode,
return R.ok(mobileSeedService.practiceReviews(currentAppUsername())); @RequestParam(required = false) String extPartyId,
@RequestParam(required = false) Integer limit) {
return R.ok(mobileSeedService.practiceReviews(currentAppUsername(), projectCode, extPartyId, limit));
} }
@GetMapping("/practice/reviews/{id}") @GetMapping("/practice/reviews/{id}")
@@ -57,6 +57,10 @@ public class AihrMobileSeedService {
return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, 5); return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, 5);
} }
public List<RecordResponse> practiceReviews(String supervisorExtPartyId, String projectCode, String extPartyId, Integer limit) {
return practiceSeedService.pendingReviewRecords(supervisorExtPartyId, projectCode, extPartyId, limit == null ? 20 : limit);
}
public ReviewDetailResponse practiceReview(Long id) { public ReviewDetailResponse practiceReview(Long id) {
return practiceSeedService.reviewDetail(id); return practiceSeedService.reviewDetail(id);
} }
@@ -478,37 +478,55 @@ public class AihrPracticeSeedService {
} }
public List<RecordResponse> pendingReviewRecords(int limit) { public List<RecordResponse> pendingReviewRecords(int limit) {
ensurePracticeTable(); return pendingReviewRecords("", "", "", limit);
return jdbcTemplate.query("""
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'
ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC
LIMIT ?
""", this::mapRecord, TENANT_ID, normalizeLimit(limit));
} }
public List<RecordResponse> pendingReviewRecords(String supervisorExtPartyId, int limit) { public List<RecordResponse> pendingReviewRecords(String supervisorExtPartyId, int limit) {
TeamScope scope = teamScope(supervisorExtPartyId); return pendingReviewRecords(supervisorExtPartyId, "", "", limit);
if (!scope.scoped()) {
return pendingReviewRecords(limit);
} }
if (scope.extPartyIds().isEmpty()) {
public List<RecordResponse> pendingReviewRecords(String supervisorExtPartyId, String projectCode, String extPartyId, int limit) {
TeamScope scope = teamScope(supervisorExtPartyId);
if (scope.scoped() && scope.extPartyIds().isEmpty()) {
return List.of();
}
String project = isBlank(projectCode) ? "" : projectCode.trim();
if (!project.isEmpty() && !orgSnapshotTableExists()) {
return List.of(); return List.of();
} }
ensurePracticeTable(); ensurePracticeTable();
List<Object> args = new ArrayList<>(); List<Object> args = new ArrayList<>();
args.add(TENANT_ID); args.add(TENANT_ID);
StringBuilder where = new StringBuilder("WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘'");
String party = isBlank(extPartyId) ? "" : extPartyId.trim();
if (!party.isEmpty()) {
where.append(" AND ext_party_id = ?");
args.add(party);
}
if (!project.isEmpty()) {
where.append("""
AND ext_party_id IN (
SELECT ext_party_id
FROM aihr_org_snapshot
WHERE tenant_id = ? AND project_code = ? AND employment_status = 'active'
)
""");
args.add(TENANT_ID);
args.add(project);
}
if (scope.scoped()) {
where.append(" AND ext_party_id IN (").append(inClause(scope.extPartyIds().size())).append(")");
args.addAll(scope.extPartyIds()); args.addAll(scope.extPartyIds());
}
args.add(normalizeLimit(limit)); args.add(normalizeLimit(limit));
return jdbcTemplate.query(""" String sql = """
SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id SELECT id, session_id, finished_time, trainee_name, scenario_name, total_score, status, summary, ext_party_id
FROM aihr_practice_session FROM aihr_practice_session
WHERE tenant_id = ? AND mode = 'mobile' AND status = '待复盘' """ + where + """
AND ext_party_id IN (%s)
ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC ORDER BY CASE WHEN total_score < 80 THEN 0 ELSE 1 END, total_score ASC, finished_time DESC, id DESC
LIMIT ? LIMIT ?
""".formatted(inClause(scope.extPartyIds().size())), this::mapRecord, args.toArray()); """;
return jdbcTemplate.query(sql, this::mapRecord, args.toArray());
} }
public String exportPilotCsv() { public String exportPilotCsv() {
@@ -3,6 +3,7 @@ package org.dromara.aihr.service;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest; import org.dromara.aihr.domain.AihrPracticeDto.PracticeAssignmentRequest;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers; import org.mockito.ArgumentMatchers;
import org.mockito.invocation.InvocationOnMock; import org.mockito.invocation.InvocationOnMock;
@@ -11,17 +12,20 @@ import org.springframework.jdbc.core.RowMapper;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@Tag("dev")
public class AihrPracticeSeedServiceTest { public class AihrPracticeSeedServiceTest {
@Test @Test
@@ -64,13 +68,101 @@ public class AihrPracticeSeedServiceTest {
assertEquals("只能派发给当前项目范围内员工", error.getMessage()); assertEquals("只能派发给当前项目范围内员工", error.getMessage());
} }
@Test
public void pendingReviewRecordsAppliesProjectAndAssigneeFilters() {
PracticeReviewJdbcTemplate jdbcTemplate = new PracticeReviewJdbcTemplate("主管", List.of("SUP-1", "EMP-1"));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
service.pendingReviewRecords("SUP-1", "P1", "EMP-1", 50);
assertTrue(jdbcTemplate.reviewSql.contains("status = '待复盘'"));
assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id = ?"));
assertTrue(jdbcTemplate.reviewSql.contains("project_code = ?"));
assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id IN (?,?)"));
assertEquals(List.of("000000", "EMP-1", "000000", "P1", "SUP-1", "EMP-1", 20), jdbcTemplate.reviewArgs);
}
@Test
public void pendingReviewRecordsLimitsNonSupervisorToSelf() {
PracticeReviewJdbcTemplate jdbcTemplate = new PracticeReviewJdbcTemplate("员工", List.of("SUP-1", "EMP-1"));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
service.pendingReviewRecords("SUP-1", "", "", 0);
assertTrue(jdbcTemplate.reviewSql.contains("ext_party_id IN (?)"));
assertEquals(List.of("000000", "SUP-1", 5), jdbcTemplate.reviewArgs);
}
@SuppressWarnings({"unchecked", "rawtypes"}) @SuppressWarnings({"unchecked", "rawtypes"})
private static <T> List<T> mapOne(InvocationOnMock invocation, Map<String, String> values) throws SQLException { private static <T> List<T> mapOne(InvocationOnMock invocation, Map<String, String> values) throws SQLException {
RowMapper mapper = invocation.getArgument(1); RowMapper mapper = invocation.getArgument(1);
return mapRows(mapper, List.of(values));
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static <T> List<T> mapRows(RowMapper mapper, List<Map<String, String>> rows) throws SQLException {
List<T> mapped = new ArrayList<>();
int index = 0;
for (Map<String, String> values : rows) {
ResultSet resultSet = mock(ResultSet.class); ResultSet resultSet = mock(ResultSet.class);
for (Map.Entry<String, String> entry : values.entrySet()) { for (Map.Entry<String, String> entry : values.entrySet()) {
when(resultSet.getString(entry.getKey())).thenReturn(entry.getValue()); when(resultSet.getString(entry.getKey())).thenReturn(entry.getValue());
} }
return List.of((T) mapper.mapRow(resultSet, 0)); mapped.add((T) mapper.mapRow(resultSet, index++));
} }
return mapped;
}
private static final class PracticeReviewJdbcTemplate extends JdbcTemplate {
private final String ownerLevel;
private final List<String> scopeIds;
private String reviewSql = "";
private List<Object> reviewArgs = List.of();
private PracticeReviewJdbcTemplate(String ownerLevel, List<String> scopeIds) {
this.ownerLevel = ownerLevel;
this.scopeIds = scopeIds;
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast(1);
}
@Override
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public void execute(String sql) {
// Table creation is irrelevant for query assembly tests.
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
try {
if (sql.contains("FROM aihr_practice_session")) {
reviewSql = sql;
reviewArgs = List.of(args);
return List.of();
}
if (sql.contains("SELECT project_code") && sql.contains("LIMIT 1")) {
return mapRows(rowMapper, List.of(Map.of(
"project_code", "P1",
"position_level", ownerLevel
)));
}
if (sql.contains("SELECT ext_party_id") && sql.contains("ORDER BY position_level")) {
return mapRows(rowMapper, scopeIds.stream()
.map(id -> Map.of("ext_party_id", id))
.toList());
}
return List.of();
} catch (SQLException e) {
throw new IllegalStateException(e);
}
}
}
} }
+3 -2
View File
@@ -6,8 +6,8 @@
| 优先级 | 差异项 | 当前状态 | 本次实现 | 验收口径 | | 优先级 | 差异项 | 当前状态 | 本次实现 | 验收口径 |
|---|---|---|---|---| |---|---|---|---|---|
| P0 | 组织人员 / 岗位 / 项目权限运营页 | 组织同步 API 存在,侧栏未暴露运营视图 | 新增 `组织与权限` 页面,呈现组织人员快照、岗位映射、项目数据范围和同步预检入口 | PC 可查询人员快照,看到身份三轴、权限映射、同步结果边界 | | P0 | 组织人员 / 岗位 / 项目权限运营页 | 组织同步 API 存在,侧栏未暴露运营视图 | 新增 `组织与权限` 页面,呈现组织人员快照、岗位映射、项目数据范围和同步预检入口 | PC 可查询人员快照,看到身份三轴、权限映射、同步结果边界 |
| P0 | 主管复盘 + 人工校准 + 派发训练闭环 | 后端已有复盘、评分校准、派发接口;PC 只有散落入口 | 新增 `复盘校准` 页面,集中待复盘列表、详情、复盘建议、学分、下次训练派发 | 主管能从一页完成“看记录 -> 写建议 -> 派训练” | | P0 | 主管复盘 + 人工校准 + 派发训练闭环 | 后端已有复盘、评分校准、派发接口;PC 只有散落入口 | 新增 `复盘校准` 页面,并由后端基于组织快照做项目/人员筛选 | 主管能从一页完成“看记录 -> 写建议 -> 派训练” |
| P0 | 能力画像 / 等级证据包 / 激励规则 | 后端已有画像和证据包接口;PC 无独立页 | 新增 `成长激励` 页面,展示画像、等级路径、证据包、预警、任务与最小规则表 | HR 能看到初/中/高级证据和学分规则口径 | | P0 | 能力画像 / 等级证据包 / 激励规则 | 后端已有画像和证据包接口;PC 无独立页 | 新增 `成长激励` 页面,并用组织快照选择员工查看画像和派发专项 | HR 能看到初/中/高级证据和学分规则口径 |
| P1 | 学模块课程 / 考试后台 | 文档要求阶段二建设;当前无页面 | 暂不实现,保留 TODO | 阶段二专项设计后再建课程、课件、考试、成绩表 | | P1 | 学模块课程 / 考试后台 | 文档要求阶段二建设;当前无页面 | 暂不实现,保留 TODO | 阶段二专项设计后再建课程、课件、考试、成绩表 |
| P1 | 大喇叭文件分发 | 总纲阶段二;当前无页面 | 暂不实现,保留 TODO | 半年会后按身份三轴做摘要、已读未读、补推 | | P1 | 大喇叭文件分发 | 总纲阶段二;当前无页面 | 暂不实现,保留 TODO | 半年会后按身份三轴做摘要、已读未读、补推 |
| P1 | 开放问题榜 | 阶段一只埋 `knowledge_gap`;当前无 UI | 暂不实现,保留 TODO | 阶段二消费 gap 池、上榜、最佳答案、沉淀入库 | | P1 | 开放问题榜 | 阶段一只埋 `knowledge_gap`;当前无 UI | 暂不实现,保留 TODO | 阶段二消费 gap 池、上榜、最佳答案、沉淀入库 |
@@ -19,4 +19,5 @@
- 不新建数据库表。 - 不新建数据库表。
- 不改登录、密钥、模型配置、支付、部署脚本。 - 不改登录、密钥、模型配置、支付、部署脚本。
- 优先复用已有 `/api/aihr/mobile/practice/*` 与 `/api/train/practice/*` 接口。 - 优先复用已有 `/api/aihr/mobile/practice/*` 与 `/api/train/practice/*` 接口。
- 组织人员快照在 `组织与权限` 页分页展示;业务页只取下拉候选,真实复盘范围由接口参数落到后端过滤。
- 规则配置中心先做运营口径展示;需要真实持久化时再补 `incentive_rule` 等表。 - 规则配置中心先做运营口径展示;需要真实持久化时再补 `incentive_rule` 等表。
+9 -2
View File
@@ -154,10 +154,17 @@ export type OrgSnapshotResponse = {
rows: OrgPersonRow[]; rows: OrgPersonRow[];
}; };
export function listPracticeReviews(): Promise<ApiResult<ReviewRecord[]>> { export type ReviewListParams = {
projectCode?: string;
extPartyId?: string;
limit?: number;
};
export function listPracticeReviews(params?: ReviewListParams): Promise<ApiResult<ReviewRecord[]>> {
return request({ return request({
url: '/api/aihr/mobile/practice/reviews', url: '/api/aihr/mobile/practice/reviews',
method: 'get' method: 'get',
params
}); });
} }
+50 -5
View File
@@ -6,7 +6,17 @@
<p>展示能力画像、初/中/高级证据包、预警、待训任务和学分规则最小集。</p> <p>展示能力画像、初/中/高级证据包、预警、待训任务和学分规则最小集。</p>
</div> </div>
<div class="head-actions"> <div class="head-actions">
<el-input v-model="extPartyId" clearable placeholder="员工手机号或 extPartyId" /> <el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
<el-select v-model="extPartyId" clearable filterable allow-create default-first-option placeholder="组织人员或 extPartyId">
<el-option
v-for="person in orgPeople"
:key="person.extPartyId"
:label="`${person.personName} / ${person.positionName}`"
:value="person.extPartyId"
/>
</el-select>
<el-button type="danger" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button> <el-button type="danger" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
</div> </div>
</section> </section>
@@ -123,27 +133,31 @@
<script setup name="CompetencyGrowth" lang="ts"> <script setup name="CompetencyGrowth" lang="ts">
import { Refresh } from '@element-plus/icons-vue'; import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { listPracticeScenarios } from '@/api/aihr/practice'; import { listPracticeScenarios } from '@/api/aihr/practice';
import { import {
createPracticeAssignment, createPracticeAssignment,
getCompetencyProfile, getCompetencyProfile,
getOrgSnapshot,
getPromotionEvidence, getPromotionEvidence,
listPracticeAlerts, listPracticeAlerts,
listPracticeAssignments, listPracticeAssignments,
type CompetencyProfile, type CompetencyProfile,
type OrgSnapshotResponse,
type PracticeAlert, type PracticeAlert,
type PracticeAssignment, type PracticeAssignment,
type PromotionEvidence type PromotionEvidence
} from '@/api/aihr/mobile-admin'; } from '@/api/aihr/mobile-admin';
const extPartyId = ref(''); const extPartyId = ref('');
const selectedProjectCode = ref('');
const loading = ref(false); const loading = ref(false);
const leaderboardEnabled = ref(false); const leaderboardEnabled = ref(false);
const profile = ref<CompetencyProfile | null>(null); const profile = ref<CompetencyProfile | null>(null);
const evidence = ref<PromotionEvidence | null>(null); const evidence = ref<PromotionEvidence | null>(null);
const alerts = ref<PracticeAlert[]>([]); const alerts = ref<PracticeAlert[]>([]);
const assignments = ref<PracticeAssignment[]>([]); const assignments = ref<PracticeAssignment[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const rules = [ const rules = [
{ source: '训练完成', points: '+5', guard: '只认完成记录,不按点击或打开计分' }, { source: '训练完成', points: '+5', guard: '只认完成记录,不按点击或打开计分' },
@@ -152,6 +166,23 @@ const rules = [
{ source: '课程考试通过', points: '+10', guard: '阶段二课程考试上线后启用' } { source: '课程考试通过', points: '+10', guard: '阶段二课程考试上线后启用' }
]; ];
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const selectedPerson = computed(() => orgPeople.value.find((item) => item.extPartyId === extPartyId.value));
const loadOrgSnapshot = async () => {
try {
const result = await getOrgSnapshot({
status: 'active',
projectCode: selectedProjectCode.value,
pageNum: 1,
pageSize: 500
});
orgSnapshot.value = result.data;
} catch (error) {
orgSnapshot.value = null;
}
};
const loadAll = async () => { const loadAll = async () => {
loading.value = true; loading.value = true;
const party = extPartyId.value.trim(); const party = extPartyId.value.trim();
@@ -184,7 +215,7 @@ const createManualAssignment = async () => {
const scenario = scenarios.find((item) => item.enabled) || scenarios[0]; const scenario = scenarios.find((item) => item.enabled) || scenarios[0];
await createPracticeAssignment({ await createPracticeAssignment({
extPartyId: party, extPartyId: party,
traineeName: party, traineeName: selectedPerson.value?.personName || party,
scenarioId: scenario?.id || 'fee-parking', scenarioId: scenario?.id || 'fee-parking',
scenarioName: scenario?.name, scenarioName: scenario?.name,
source: 'manual', source: 'manual',
@@ -197,7 +228,19 @@ const createManualAssignment = async () => {
} }
}; };
onMounted(loadAll); watch(selectedProjectCode, async () => {
extPartyId.value = '';
await loadOrgSnapshot();
});
watch(extPartyId, () => {
loadAll();
});
onMounted(() => {
loadOrgSnapshot();
loadAll();
});
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -236,7 +279,7 @@ onMounted(loadAll);
.head-actions { .head-actions {
display: grid; display: grid;
grid-template-columns: 260px auto; grid-template-columns: 220px 280px auto;
gap: 10px; gap: 10px;
} }
@@ -357,6 +400,8 @@ onMounted(loadAll);
} }
.head-actions { .head-actions {
display: grid;
grid-template-columns: 1fr;
margin-top: 14px; margin-top: 14px;
} }
} }
+80 -8
View File
@@ -14,9 +14,22 @@
<article class="panel list-panel"> <article class="panel list-panel">
<div class="panel-title"> <div class="panel-title">
<h2>待复盘记录</h2> <h2>待复盘记录</h2>
<el-tag type="danger">{{ reviews.length }} 条</el-tag> <el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
</div> </div>
<el-table v-loading="loading" :data="reviews" size="small" highlight-current-row @row-click="selectReview"> <div class="list-filters">
<el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
<el-option
v-for="person in orgPeople"
:key="person.extPartyId"
:label="`${person.personName} / ${person.positionName}`"
:value="person.extPartyId"
/>
</el-select>
</div>
<el-table v-loading="loading" :data="visibleReviews" size="small" highlight-current-row @row-click="selectReview">
<el-table-column prop="trainee" label="学员" width="96" /> <el-table-column prop="trainee" label="学员" width="96" />
<el-table-column prop="scene" label="场景" min-width="150" show-overflow-tooltip /> <el-table-column prop="scene" label="场景" min-width="150" show-overflow-tooltip />
<el-table-column prop="score" label="得分" width="70" /> <el-table-column prop="score" label="得分" width="70" />
@@ -92,15 +105,26 @@
<script setup name="TrainReviews" lang="ts"> <script setup name="TrainReviews" lang="ts">
import { Refresh } from '@element-plus/icons-vue'; import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice'; import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import { getPracticeReview, listPracticeReviews, markPracticeReviewed, type ReviewDetail, type ReviewRecord } from '@/api/aihr/mobile-admin'; import {
getOrgSnapshot,
getPracticeReview,
listPracticeReviews,
markPracticeReviewed,
type OrgSnapshotResponse,
type ReviewDetail,
type ReviewRecord
} from '@/api/aihr/mobile-admin';
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const reviews = ref<ReviewRecord[]>([]); const reviews = ref<ReviewRecord[]>([]);
const detail = ref<ReviewDetail | null>(null); const detail = ref<ReviewDetail | null>(null);
const scenarios = ref<PracticeScenarioResponse[]>([]); const scenarios = ref<PracticeScenarioResponse[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const selectedProjectCode = ref('');
const selectedExtPartyId = ref('');
const reviewForm = reactive({ const reviewForm = reactive({
reviewer: '主管', reviewer: '主管',
@@ -110,6 +134,9 @@ const reviewForm = reactive({
nextReason: '' nextReason: ''
}); });
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const visibleReviews = computed(() => reviews.value);
const loadScenarios = async () => { const loadScenarios = async () => {
try { try {
const result = await listPracticeScenarios(); const result = await listPracticeScenarios();
@@ -119,14 +146,30 @@ const loadScenarios = async () => {
} }
}; };
const loadOrgSnapshot = async () => {
try {
const result = await getOrgSnapshot({
status: 'active',
projectCode: selectedProjectCode.value,
pageNum: 1,
pageSize: 500
});
orgSnapshot.value = result.data;
} catch (error) {
orgSnapshot.value = null;
}
};
const loadReviews = async () => { const loadReviews = async () => {
loading.value = true; loading.value = true;
try { try {
const result = await listPracticeReviews(); const result = await listPracticeReviews({
projectCode: selectedProjectCode.value,
extPartyId: selectedExtPartyId.value,
limit: 50
});
reviews.value = result.data || []; reviews.value = result.data || [];
if (!detail.value && reviews.value.length > 0) { await selectFirstVisibleReview();
await selectReview(reviews.value[0]);
}
} catch (error) { } catch (error) {
ElMessage.error('待复盘列表加载失败'); ElMessage.error('待复盘列表加载失败');
} finally { } finally {
@@ -143,6 +186,16 @@ const selectReview = async (row: ReviewRecord) => {
reviewForm.nextReason = ''; reviewForm.nextReason = '';
}; };
const selectFirstVisibleReview = async () => {
if (visibleReviews.value.some((item) => item.id === detail.value?.id)) return;
const first = visibleReviews.value[0];
if (!first) {
detail.value = null;
return;
}
await selectReview(first);
};
const submitReview = async () => { const submitReview = async () => {
if (!detail.value) return; if (!detail.value) return;
saving.value = true; saving.value = true;
@@ -164,8 +217,19 @@ const submitReview = async () => {
} }
}; };
watch(selectedProjectCode, async () => {
selectedExtPartyId.value = '';
await loadOrgSnapshot();
await loadReviews();
});
watch(selectedExtPartyId, () => {
loadReviews();
});
onMounted(() => { onMounted(() => {
loadScenarios(); loadScenarios();
loadOrgSnapshot();
loadReviews(); loadReviews();
}); });
</script> </script>
@@ -227,6 +291,13 @@ onMounted(() => {
} }
} }
.list-filters {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.score-grid, .score-grid,
.form-grid { .form-grid {
display: grid; display: grid;
@@ -287,6 +358,7 @@ onMounted(() => {
@media (max-width: 1080px) { @media (max-width: 1080px) {
.review-layout, .review-layout,
.list-filters,
.score-grid, .score-grid,
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;