feat(personal): add reviewed enterprise publishing
This commit is contained in:
+15
@@ -8,6 +8,8 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreate
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportPptRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestCreateRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.OcrProgressResponse;
|
||||
@@ -24,6 +26,7 @@ import org.dromara.aihr.personal.service.PersonalCleanupService;
|
||||
import org.dromara.aihr.personal.service.PersonalIngestionService;
|
||||
import org.dromara.aihr.personal.service.PersonalExportService;
|
||||
import org.dromara.aihr.personal.service.PersonalPdfOcrService;
|
||||
import org.dromara.aihr.personal.service.PersonalPublishService;
|
||||
import org.dromara.aihr.personal.service.PersonalRetrievalService;
|
||||
import org.dromara.aihr.personal.service.PersonalSpaceService;
|
||||
import org.dromara.aihr.personal.service.PersonalUrlFetchService;
|
||||
@@ -63,6 +66,7 @@ public class PersonalAssistantController {
|
||||
private final PersonalCleanupService cleanupService;
|
||||
private final PersonalPdfOcrService pdfOcrService;
|
||||
private final PersonalExportService exportService;
|
||||
private final PersonalPublishService publishService;
|
||||
|
||||
@GetMapping("/space")
|
||||
public R<SpaceResponse> space() {
|
||||
@@ -191,6 +195,17 @@ public class PersonalAssistantController {
|
||||
return R.ok(exportService.downloadUrl(owner(), id));
|
||||
}
|
||||
|
||||
@PostMapping("/items/{id}/publish-requests")
|
||||
public R<PublishRequestResponse> requestPublish(@PathVariable long id,
|
||||
@RequestBody PublishRequestCreateRequest request) {
|
||||
return R.ok(publishService.request(owner(), id, request));
|
||||
}
|
||||
|
||||
@GetMapping("/publish-requests")
|
||||
public R<List<PublishRequestResponse>> publishRequests() {
|
||||
return R.ok(publishService.requests(owner()));
|
||||
}
|
||||
|
||||
private PersonalOwner owner() {
|
||||
return ownerProvider.current();
|
||||
}
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package org.dromara.aihr.personal.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckRole;
|
||||
import cn.dev33.satoken.annotation.SaMode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishReviewRequest;
|
||||
import org.dromara.aihr.personal.service.PersonalPublishService;
|
||||
import org.dromara.aihr.personal.support.PersonalOwnerProvider;
|
||||
import org.dromara.common.core.constant.TenantConstants;
|
||||
import org.dromara.common.core.domain.R;
|
||||
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.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/aihr/personal-assistant/admin/publish-requests")
|
||||
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, "hr_operator"}, mode = SaMode.OR)
|
||||
public class PersonalPublishAdminController {
|
||||
|
||||
private final PersonalOwnerProvider ownerProvider;
|
||||
private final PersonalPublishService publishService;
|
||||
|
||||
@GetMapping
|
||||
public R<List<PublishRequestResponse>> pending() {
|
||||
return R.ok(publishService.pending(ownerProvider.current()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/review")
|
||||
public R<PublishRequestResponse> review(@PathVariable long id, @RequestBody PublishReviewRequest request) {
|
||||
return R.ok(publishService.review(ownerProvider.current(), id, request));
|
||||
}
|
||||
}
|
||||
+12
@@ -110,6 +110,18 @@ public final class PersonalAssistantDto {
|
||||
Long ossId) {
|
||||
}
|
||||
|
||||
public record PublishRequestCreateRequest(String targetScope, String reason) {
|
||||
}
|
||||
|
||||
public record PublishReviewRequest(boolean approved, String comment) {
|
||||
}
|
||||
|
||||
public record PublishRequestResponse(long id, long itemId, String title, String sourceType,
|
||||
String targetScope, String reason, String sanitizedPreview, String status,
|
||||
Long reviewerUserId, String reviewComment,
|
||||
Long publishedKnowledgeId, LocalDateTime createdAt) {
|
||||
}
|
||||
|
||||
private static Long legacyId(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try {
|
||||
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package org.dromara.aihr.personal.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestCreateRequest;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestResponse;
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishReviewRequest;
|
||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
@Service
|
||||
public class PersonalPublishService {
|
||||
|
||||
private static final String ALLOWED_SCOPE = "POSITION:生活顾问";
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final EnterprisePublisher publisher;
|
||||
private final LongSupplier idSupplier;
|
||||
|
||||
@Autowired
|
||||
public PersonalPublishService(JdbcTemplate jdbc) {
|
||||
this(jdbc, new DefaultEnterprisePublisher(jdbc), IdWorker::getId);
|
||||
}
|
||||
|
||||
private PersonalPublishService(JdbcTemplate jdbc, EnterprisePublisher publisher, LongSupplier idSupplier) {
|
||||
this.jdbc = jdbc;
|
||||
this.publisher = publisher;
|
||||
this.idSupplier = idSupplier;
|
||||
}
|
||||
|
||||
public static PersonalPublishService forTest(JdbcTemplate jdbc, EnterprisePublisher publisher,
|
||||
LongSupplier idSupplier) {
|
||||
return new PersonalPublishService(jdbc, publisher, idSupplier);
|
||||
}
|
||||
|
||||
public PublishRequestResponse request(PersonalOwner owner, long itemId, PublishRequestCreateRequest request) {
|
||||
requireOwner(owner);
|
||||
String targetScope = clean(request == null ? null : request.targetScope());
|
||||
String reason = clean(request == null ? null : request.reason());
|
||||
if (!ALLOWED_SCOPE.equals(targetScope) || reason.isEmpty()) {
|
||||
throw new ServiceException("PERSONAL_PUBLISH_REQUEST_INVALID");
|
||||
}
|
||||
List<Map<String, Object>> items = jdbc.queryForList("""
|
||||
select id, title, source_type from aihr_personal_item
|
||||
where binary tenant_id = binary ? and owner_user_id = ? and id = ? and status = 'READY'
|
||||
limit 1
|
||||
""", owner.tenantId(), owner.userId(), itemId);
|
||||
if (items.size() != 1) throw new ServiceException("PERSONAL_ITEM_NOT_FOUND");
|
||||
Integer pending = jdbc.queryForObject("""
|
||||
select count(*) from aihr_personal_publish_request
|
||||
where binary tenant_id = binary ? and applicant_user_id = ? and item_id = ? and status = 'PENDING'
|
||||
""", Integer.class, owner.tenantId(), owner.userId(), itemId);
|
||||
if (pending != null && pending > 0) throw new ServiceException("PERSONAL_PUBLISH_ALREADY_PENDING");
|
||||
long id = positive(idSupplier.getAsLong());
|
||||
int inserted = jdbc.update("""
|
||||
insert into aihr_personal_publish_request
|
||||
(id, tenant_id, item_id, applicant_user_id, target_scope, reason, status,
|
||||
create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, ?, 'PENDING', now(), now())
|
||||
""", id, owner.tenantId(), itemId, owner.userId(), targetScope, truncate(reason, 500));
|
||||
if (inserted != 1) throw new ServiceException("PERSONAL_PUBLISH_CREATE_FAILED");
|
||||
Map<String, Object> item = items.get(0);
|
||||
return new PublishRequestResponse(id, itemId, clean(String.valueOf(item.get("title"))),
|
||||
clean(String.valueOf(item.get("source_type"))), targetScope, reason, null, "PENDING",
|
||||
null, null, null, LocalDateTime.now());
|
||||
}
|
||||
|
||||
public List<PublishRequestResponse> requests(PersonalOwner owner) {
|
||||
requireOwner(owner);
|
||||
return rows(jdbc.queryForList("""
|
||||
select r.*, i.title, i.source_type
|
||||
from aihr_personal_publish_request r
|
||||
join aihr_personal_item i on i.id = r.item_id and binary i.tenant_id = binary r.tenant_id
|
||||
and i.owner_user_id = r.applicant_user_id
|
||||
where binary r.tenant_id = binary ? and r.applicant_user_id = ?
|
||||
order by r.create_time desc, r.id desc limit 100
|
||||
""", owner.tenantId(), owner.userId()));
|
||||
}
|
||||
|
||||
public List<PublishRequestResponse> pending(PersonalOwner reviewer) {
|
||||
requireOwner(reviewer);
|
||||
return rows(jdbc.queryForList("""
|
||||
select r.*, i.title, i.source_type,
|
||||
(select group_concat(pf.content order by pf.idx separator '\n')
|
||||
from aihr_personal_fragment pf
|
||||
where binary pf.tenant_id = binary r.tenant_id
|
||||
and pf.owner_user_id = r.applicant_user_id and pf.item_id = r.item_id) preview_content
|
||||
from aihr_personal_publish_request r
|
||||
join aihr_personal_item i on i.id = r.item_id and binary i.tenant_id = binary r.tenant_id
|
||||
and i.owner_user_id = r.applicant_user_id
|
||||
where binary r.tenant_id = binary ? and r.status = 'PENDING'
|
||||
order by r.create_time, r.id limit 200
|
||||
""", reviewer.tenantId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PublishRequestResponse review(PersonalOwner reviewer, long requestId, PublishReviewRequest request) {
|
||||
requireOwner(reviewer);
|
||||
String comment = clean(request == null ? null : request.comment());
|
||||
if (requestId <= 0 || comment.isEmpty()) throw new ServiceException("PERSONAL_PUBLISH_REVIEW_INVALID");
|
||||
List<Map<String, Object>> rows = jdbc.queryForList("""
|
||||
select r.id, r.tenant_id, r.item_id, r.applicant_user_id, r.target_scope, r.reason, r.status,
|
||||
i.title, i.source_type
|
||||
from aihr_personal_publish_request r
|
||||
join aihr_personal_item i on i.id = r.item_id and binary i.tenant_id = binary r.tenant_id
|
||||
and i.owner_user_id = r.applicant_user_id
|
||||
where r.id = ? and binary r.tenant_id = binary ? and r.status = 'PENDING'
|
||||
limit 1 for update
|
||||
""", requestId, reviewer.tenantId());
|
||||
if (rows.size() != 1) throw new ServiceException("PERSONAL_PUBLISH_NOT_FOUND");
|
||||
Map<String, Object> row = rows.get(0);
|
||||
if (!request.approved()) {
|
||||
int rejected = jdbc.update("""
|
||||
update aihr_personal_publish_request
|
||||
set status = 'REJECTED', reviewer_user_id = ?, review_comment = ?,
|
||||
review_time = now(), update_time = now()
|
||||
where id = ? and status = 'PENDING'
|
||||
""", reviewer.userId(), truncate(comment, 500), requestId);
|
||||
if (rejected != 1) throw new ServiceException("PERSONAL_PUBLISH_NOT_FOUND");
|
||||
return response(row, "REJECTED", reviewer.userId(), comment, null);
|
||||
}
|
||||
long applicantId = number(row, "applicant_user_id");
|
||||
long itemId = number(row, "item_id");
|
||||
List<Map<String, Object>> fragmentRows = jdbc.queryForList("""
|
||||
select content from aihr_personal_fragment
|
||||
where binary tenant_id = binary ? and owner_user_id = ? and item_id = ?
|
||||
order by idx
|
||||
""", reviewer.tenantId(), applicantId, itemId);
|
||||
List<String> sanitized = new ArrayList<>();
|
||||
for (Map<String, Object> fragment : fragmentRows) {
|
||||
String content = PersonalPromptSanitizer.sanitize(clean(String.valueOf(fragment.get("content"))));
|
||||
if (!content.isEmpty()) sanitized.add(content);
|
||||
}
|
||||
if (sanitized.isEmpty() || sanitized.stream().anyMatch(PersonalPromptSanitizer::containsSensitive)) {
|
||||
throw new ServiceException("PERSONAL_PUBLISH_SANITIZE_FAILED");
|
||||
}
|
||||
String title = PersonalPromptSanitizer.sanitize(clean(String.valueOf(row.get("title"))));
|
||||
String scope = clean(String.valueOf(row.get("target_scope")));
|
||||
if (!ALLOWED_SCOPE.equals(scope)) throw new ServiceException("PERSONAL_PUBLISH_SCOPE_FORBIDDEN");
|
||||
long knowledgeId = publisher.publish(reviewer.tenantId(), reviewer.userId(), requestId,
|
||||
title, scope, List.copyOf(sanitized));
|
||||
int approved = jdbc.update("""
|
||||
update aihr_personal_publish_request
|
||||
set status = 'APPROVED', reviewer_user_id = ?, review_comment = ?,
|
||||
published_knowledge_id = ?, review_time = now(), update_time = now()
|
||||
where id = ? and status = ?
|
||||
""", reviewer.userId(), truncate(comment, 500), knowledgeId, requestId, "PENDING");
|
||||
if (approved != 1) throw new ServiceException("PERSONAL_PUBLISH_NOT_FOUND");
|
||||
return response(row, "APPROVED", reviewer.userId(), comment, knowledgeId);
|
||||
}
|
||||
|
||||
private static List<PublishRequestResponse> rows(List<Map<String, Object>> rows) {
|
||||
return rows.stream().map(row -> response(row, clean(String.valueOf(row.get("status"))),
|
||||
nullableLong(row.get("reviewer_user_id")), cleanNullable(row.get("review_comment")),
|
||||
nullableLong(row.get("published_knowledge_id")))).toList();
|
||||
}
|
||||
|
||||
private static PublishRequestResponse response(Map<String, Object> row, String status, Long reviewerId,
|
||||
String comment, Long knowledgeId) {
|
||||
Object created = row.get("create_time");
|
||||
LocalDateTime createdAt = created instanceof java.sql.Timestamp timestamp
|
||||
? timestamp.toLocalDateTime() : created instanceof LocalDateTime time ? time : null;
|
||||
return new PublishRequestResponse(number(row, "id"), number(row, "item_id"),
|
||||
clean(String.valueOf(row.get("title"))), clean(String.valueOf(row.get("source_type"))),
|
||||
clean(String.valueOf(row.get("target_scope"))), clean(String.valueOf(row.get("reason"))),
|
||||
sanitizedPreview(row.get("preview_content")), status, reviewerId, comment, knowledgeId, createdAt);
|
||||
}
|
||||
|
||||
private static String sanitizedPreview(Object value) {
|
||||
String preview = PersonalPromptSanitizer.sanitize(clean(value == null ? null : String.valueOf(value)));
|
||||
return preview.isEmpty() ? null : truncate(preview, 500);
|
||||
}
|
||||
|
||||
private static void requireOwner(PersonalOwner owner) {
|
||||
if (owner == null || owner.userId() <= 0 || clean(owner.tenantId()).isEmpty()) {
|
||||
throw new ServiceException("PERSONAL_OWNER_REQUIRED");
|
||||
}
|
||||
}
|
||||
|
||||
private static long number(Map<String, Object> row, String key) {
|
||||
Object value = row.get(key);
|
||||
if (!(value instanceof Number number)) throw new ServiceException("PERSONAL_PUBLISH_DATA_INVALID");
|
||||
return number.longValue();
|
||||
}
|
||||
|
||||
private static Long nullableLong(Object value) {
|
||||
return value instanceof Number number ? number.longValue() : null;
|
||||
}
|
||||
|
||||
private static String cleanNullable(Object value) {
|
||||
String text = clean(value == null ? null : String.valueOf(value));
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
private static long positive(long value) {
|
||||
if (value <= 0) throw new ServiceException("PERSONAL_PUBLISH_CREATE_FAILED");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String clean(String value) {
|
||||
return value == null || "null".equals(value) ? "" : value.trim();
|
||||
}
|
||||
|
||||
private static String truncate(String value, int max) {
|
||||
return value.length() <= max ? value : value.substring(0, max);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface EnterprisePublisher {
|
||||
long publish(String tenantId, long reviewerUserId, long requestId, String title,
|
||||
String targetScope, List<String> sanitizedFragments);
|
||||
}
|
||||
|
||||
private static final class DefaultEnterprisePublisher implements EnterprisePublisher {
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
private DefaultEnterprisePublisher(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long publish(String tenantId, long reviewerUserId, long requestId, String title,
|
||||
String targetScope, List<String> sanitizedFragments) {
|
||||
long knowledgeId = IdWorker.getId();
|
||||
String docId = UUID.randomUUID().toString().replace("-", "");
|
||||
int knowledge = jdbc.update("""
|
||||
insert into aihr_knowledge_info
|
||||
(id, tenant_id, name, description, `separator`, overlap_char, retrieve_limit,
|
||||
similarity_threshold, text_block_size, enable_hybrid, hybrid_alpha, system_prompt,
|
||||
create_by, create_time, update_by, update_time, remark)
|
||||
values (?, ?, ?, '由个人知识审核脱敏后生成', '\n\n', 120, 5, 0.5, 800, 1, 1.0,
|
||||
'回答必须引用已审核片段,不得还原个人敏感信息。', ?, now(), ?, now(), ?)
|
||||
""", knowledgeId, tenantId, truncate("个人沉淀 · " + title, 100), reviewerUserId,
|
||||
reviewerUserId, "personal-publish:" + requestId);
|
||||
if (knowledge != 1) throw new ServiceException("PERSONAL_PUBLISH_ENTERPRISE_FAILED");
|
||||
for (int index = 0; index < sanitizedFragments.size(); index++) {
|
||||
int inserted = jdbc.update("""
|
||||
insert into aihr_knowledge_fragment
|
||||
(tenant_id, knowledge_id, idx, doc_id, content, create_by, create_time,
|
||||
update_by, update_time, remark)
|
||||
values (?, ?, ?, ?, ?, ?, now(), ?, now(), ?)
|
||||
""", tenantId, knowledgeId, index + 1, docId, sanitizedFragments.get(index),
|
||||
reviewerUserId, reviewerUserId, "personal-publish:" + requestId);
|
||||
if (inserted != 1) throw new ServiceException("PERSONAL_PUBLISH_ENTERPRISE_FAILED");
|
||||
}
|
||||
String position = targetScope.substring("POSITION:".length());
|
||||
int acl = jdbc.update("""
|
||||
insert into aihr_knowledge_acl
|
||||
(tenant_id, knowledge_id, access_scope, position_name, classification,
|
||||
enabled, create_time, update_time)
|
||||
values (?, ?, 'POSITION', ?, 'INTERNAL', 1, now(), now())
|
||||
""", tenantId, knowledgeId, position);
|
||||
if (acl != 1) throw new ServiceException("PERSONAL_PUBLISH_ENTERPRISE_FAILED");
|
||||
return knowledgeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -12,6 +12,7 @@ import org.dromara.aihr.personal.service.PersonalCleanupService;
|
||||
import org.dromara.aihr.personal.service.PersonalIngestionService;
|
||||
import org.dromara.aihr.personal.service.PersonalExportService;
|
||||
import org.dromara.aihr.personal.service.PersonalRetrievalService;
|
||||
import org.dromara.aihr.personal.service.PersonalPublishService;
|
||||
import org.dromara.aihr.personal.service.PersonalSpaceService;
|
||||
import org.dromara.aihr.personal.service.PersonalUrlFetchService;
|
||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||
@@ -51,12 +52,13 @@ class PersonalAssistantControllerTest {
|
||||
org.dromara.aihr.personal.service.PersonalPdfOcrService pdfOcr =
|
||||
mock(org.dromara.aihr.personal.service.PersonalPdfOcrService.class);
|
||||
PersonalExportService exports = mock(PersonalExportService.class);
|
||||
PersonalPublishService publishes = mock(PersonalPublishService.class);
|
||||
when(urls.fetch("https://example.com/a")).thenReturn(new PersonalUrlFetchService.FetchResult(
|
||||
java.net.URI.create("https://example.com/a"), 200, "text/plain", "a".getBytes(),
|
||||
java.time.Instant.now(), "hash"));
|
||||
|
||||
PersonalAssistantController controller = new PersonalAssistantController(owners, spaces, ingestion, urls,
|
||||
retrieval, answers, cleanup, pdfOcr, exports);
|
||||
retrieval, answers, cleanup, pdfOcr, exports, publishes);
|
||||
controller.space();
|
||||
controller.items(1, 20, null, null, null, null, null);
|
||||
controller.createText(new TextItemRequest("note", "body", null, List.of()));
|
||||
@@ -78,8 +80,12 @@ class PersonalAssistantControllerTest {
|
||||
controller.confirmOutline(7L);
|
||||
controller.enqueuePpt(7L, new ExportPptRequest("standard"));
|
||||
controller.exportDownloadUrl(7L);
|
||||
controller.requestPublish(9L,
|
||||
new org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishRequestCreateRequest(
|
||||
"POSITION:生活顾问", "沉淀经验"));
|
||||
controller.publishRequests();
|
||||
|
||||
verify(owners, times(21)).current();
|
||||
verify(owners, times(23)).current();
|
||||
assertFalse(PersonalAssistantController.class.isAnnotationPresent(SaIgnore.class));
|
||||
for (var method : PersonalAssistantController.class.getDeclaredMethods()) {
|
||||
assertFalse(method.isAnnotationPresent(SaIgnore.class), method.getName());
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package org.dromara.aihr.personal;
|
||||
|
||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.PublishReviewRequest;
|
||||
import org.dromara.aihr.personal.service.PersonalPublishService;
|
||||
import org.dromara.aihr.personal.service.PersonalPublishService.EnterprisePublisher;
|
||||
import org.dromara.aihr.personal.controller.PersonalPublishAdminController;
|
||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Tag("dev")
|
||||
class PersonalPublishServiceTest {
|
||||
|
||||
private static final PersonalOwner REVIEWER = new PersonalOwner("000000", 901L, null);
|
||||
|
||||
@Test
|
||||
void adminControllerRequiresHrOrSuperAdminRole() {
|
||||
var annotation = PersonalPublishAdminController.class.getAnnotation(cn.dev33.satoken.annotation.SaCheckRole.class);
|
||||
assertTrue(annotation != null);
|
||||
assertEquals(cn.dev33.satoken.annotation.SaMode.OR, annotation.mode());
|
||||
assertTrue(List.of(annotation.value()).contains("superadmin"));
|
||||
assertTrue(List.of(annotation.value()).contains("hr_operator"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalCreatesSanitizedEnterpriseCopyWithoutChangingPersonalOwner() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
EnterprisePublisher publisher = mock(EnterprisePublisher.class);
|
||||
when(jdbc.queryForList(contains("from aihr_personal_publish_request r"), eq(88L), eq("000000")))
|
||||
.thenReturn(List.of(Map.of(
|
||||
"id", 88L, "tenant_id", "000000", "item_id", 1201L, "applicant_user_id", 101L,
|
||||
"title", "投诉复盘", "source_type", "TEXT", "target_scope", "POSITION:生活顾问",
|
||||
"reason", "沉淀优秀经验", "status", "PENDING")));
|
||||
when(jdbc.queryForList(contains("from aihr_personal_fragment"),
|
||||
eq("000000"), eq(101L), eq(1201L))).thenReturn(List.of(Map.of(
|
||||
"content", "联系人:张三,电话13800000000。投诉闭环要明确责任人。")));
|
||||
when(publisher.publish(eq("000000"), eq(901L), eq(88L), eq("投诉复盘"),
|
||||
eq("POSITION:生活顾问"), org.mockito.ArgumentMatchers.anyList()))
|
||||
.thenReturn(7001L);
|
||||
when(jdbc.update(contains("status = 'APPROVED'"), eq(901L), eq("脱敏后可入库"), eq(7001L),
|
||||
eq(88L), eq("PENDING"))).thenReturn(1);
|
||||
PersonalPublishService service = PersonalPublishService.forTest(jdbc, publisher, () -> 5001L);
|
||||
|
||||
var result = service.review(REVIEWER, 88L, new PublishReviewRequest(true, "脱敏后可入库"));
|
||||
|
||||
assertEquals("APPROVED", result.status());
|
||||
assertEquals(7001L, result.publishedKnowledgeId());
|
||||
@SuppressWarnings("unchecked") ArgumentCaptor<List<String>> fragments = ArgumentCaptor.forClass(List.class);
|
||||
verify(publisher).publish(eq("000000"), eq(901L), eq(88L), eq("投诉复盘"),
|
||||
eq("POSITION:生活顾问"), fragments.capture());
|
||||
assertFalse(fragments.getValue().get(0).contains("张三"));
|
||||
assertFalse(fragments.getValue().get(0).contains("13800000000"));
|
||||
verify(jdbc, never()).update(contains("update aihr_personal_item set owner_user_id"),
|
||||
anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalRejectsMissingTicketOrSensitiveResidualWithoutPublishing() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
EnterprisePublisher publisher = mock(EnterprisePublisher.class);
|
||||
PersonalPublishService service = PersonalPublishService.forTest(jdbc, publisher, () -> 5001L);
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> service.review(REVIEWER, 88L, new PublishReviewRequest(true, "")));
|
||||
|
||||
verify(publisher, never()).publish(anyString(), anyLong(), anyLong(), anyString(), anyString(),
|
||||
org.mockito.ArgumentMatchers.anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingReviewExposesOnlySanitizedPreview() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
EnterprisePublisher publisher = mock(EnterprisePublisher.class);
|
||||
when(jdbc.queryForList(anyString(), eq("000000"))).thenReturn(List.of(Map.of(
|
||||
"id", 88L, "item_id", 1201L, "title", "投诉复盘", "source_type", "TEXT",
|
||||
"target_scope", "POSITION:生活顾问", "reason", "沉淀优秀经验", "status", "PENDING",
|
||||
"preview_content", "姓名:张三,联系电话:13800000000。投诉闭环要明确责任人。")));
|
||||
PersonalPublishService service = PersonalPublishService.forTest(jdbc, publisher, () -> 5001L);
|
||||
|
||||
var result = service.pending(REVIEWER).get(0);
|
||||
|
||||
ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc).queryForList(sql.capture(), eq("000000"));
|
||||
assertTrue(sql.getValue().contains("preview_content"));
|
||||
assertEquals("[姓名],联系电话:[手机号]。投诉闭环要明确责任人。", result.sanitizedPreview());
|
||||
assertFalse(result.sanitizedPreview().contains("张三"));
|
||||
assertFalse(result.sanitizedPreview().contains("13800000000"));
|
||||
}
|
||||
}
|
||||
+9
@@ -62,6 +62,7 @@ class PersonalSchemaContractTest {
|
||||
String ocrJob = tableDefinition(sql, "aihr_personal_ocr_job");
|
||||
String ocrPage = tableDefinition(sql, "aihr_personal_ocr_page");
|
||||
String exportTask = tableDefinition(sql, "aihr_personal_export_task");
|
||||
String publishRequest = tableDefinition(sql, "aihr_personal_publish_request");
|
||||
|
||||
for (String definition : new String[] {
|
||||
space, item, fragment, session, message, cleanup, ocrJob, ocrPage, exportTask
|
||||
@@ -152,6 +153,14 @@ class PersonalSchemaContractTest {
|
||||
"key `idx_personal_export_owner` (`tenant_id`, `owner_user_id`, `update_time`)"));
|
||||
assertTrue(exportTask.contains("key `idx_personal_export_status` (`status`, `update_time`)"));
|
||||
|
||||
assertTrue(publishRequest.contains("`item_id` bigint not null"));
|
||||
assertTrue(publishRequest.contains("`applicant_user_id` bigint not null"));
|
||||
assertTrue(publishRequest.contains("`target_scope` varchar(100) not null"));
|
||||
assertTrue(publishRequest.contains("`status` varchar(20) not null default 'pending'"));
|
||||
assertTrue(publishRequest.contains("`published_knowledge_id` bigint default null"));
|
||||
assertTrue(publishRequest.contains(
|
||||
"key `idx_personal_publish_status` (`tenant_id`, `status`, `update_time`)"));
|
||||
|
||||
assertFalse(sql.contains("alter table aihr_knowledge_fragment"),
|
||||
"Personal schema must not mutate enterprise knowledge tables");
|
||||
assertFalse(sql.contains("alter table `aihr_knowledge_fragment`"),
|
||||
|
||||
@@ -186,3 +186,23 @@ CREATE TABLE IF NOT EXISTS `aihr_personal_export_task` (
|
||||
KEY `idx_personal_export_owner` (`tenant_id`, `owner_user_id`, `update_time`),
|
||||
KEY `idx_personal_export_status` (`status`, `update_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人AI助理汇报导出任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_personal_publish_request` (
|
||||
`id` bigint NOT NULL COMMENT '发布申请ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`item_id` bigint NOT NULL COMMENT '个人资料ID',
|
||||
`applicant_user_id` bigint NOT NULL COMMENT '申请人用户ID',
|
||||
`target_scope` varchar(100) NOT NULL COMMENT '目标范围,阶段二仅POSITION:生活顾问',
|
||||
`reason` varchar(500) NOT NULL COMMENT '申请原因',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/APPROVED/REJECTED',
|
||||
`reviewer_user_id` bigint DEFAULT NULL COMMENT '审核人用户ID',
|
||||
`review_comment` varchar(500) DEFAULT NULL COMMENT '审核意见',
|
||||
`review_time` datetime DEFAULT NULL COMMENT '审核时间',
|
||||
`published_knowledge_id` bigint DEFAULT NULL COMMENT '企业知识库副本ID',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_personal_publish_applicant` (`tenant_id`, `applicant_user_id`, `create_time`),
|
||||
KEY `idx_personal_publish_status` (`tenant_id`, `status`, `update_time`),
|
||||
KEY `idx_personal_publish_item` (`tenant_id`, `item_id`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='个人知识申请沉淀企业知识审核';
|
||||
|
||||
@@ -1181,7 +1181,7 @@ Expected: 浏览器控制台无业务错误;失败提示明确;不出现无
|
||||
- Modify: mobile-uni/src/services/personal-assistant.ts
|
||||
- Modify: mobile-uni/src/pages/user/assistant/item.vue
|
||||
|
||||
- [ ] **Step 1: 写审核链失败测试**
|
||||
- [x] **Step 1: 写审核链失败测试**
|
||||
|
||||
@Test
|
||||
void approvalCreatesEnterpriseCopyWithoutChangingPersonalOwner() {
|
||||
@@ -1192,7 +1192,7 @@ Expected: 浏览器控制台无业务错误;失败提示明确;不出现无
|
||||
eq(reviewer.userId()), eq("脱敏后可入库"), eq(88L), eq("PENDING"));
|
||||
}
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
- [x] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -1200,7 +1200,7 @@ Run:
|
||||
|
||||
Expected: FAIL,发布服务和表不存在。
|
||||
|
||||
- [ ] **Step 3: 添加 publish request 表和服务**
|
||||
- [x] **Step 3: 添加 publish request 表和服务**
|
||||
|
||||
表字段固定为:tenant_id、item_id、applicant_user_id、target_scope、reason、status、reviewer_user_id、review_comment、review_time、published_knowledge_id、create_time、update_time。
|
||||
|
||||
@@ -1217,7 +1217,7 @@ Expected: FAIL,发布服务和表不存在。
|
||||
data: payload
|
||||
});
|
||||
|
||||
- [ ] **Step 4: 运行测试**
|
||||
- [x] **Step 4: 运行测试**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -1227,7 +1227,7 @@ Run:
|
||||
|
||||
Expected: PASS;批准生成企业副本;个人 item owner 不变;员工可查看申请状态;HR/运营可审核。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
- [x] **Step 5: 提交**
|
||||
|
||||
git add backend/script/sql/aihr_personal_knowledge_mysql8.sql backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/PersonalPublishServiceTest.java frontend/src/api/aihr/personal.ts frontend/src/views/knowledge/processing.vue mobile-uni/src/services/personal-assistant.ts mobile-uni/src/pages/user/assistant/item.vue
|
||||
git commit -m "feat(personal): add reviewed enterprise publishing"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface PersonalPublishRequestRow {
|
||||
id: string;
|
||||
itemId: string;
|
||||
title: string;
|
||||
sourceType: string;
|
||||
targetScope: string;
|
||||
reason: string;
|
||||
sanitizedPreview?: string;
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
reviewComment?: string;
|
||||
publishedKnowledgeId?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export const listPersonalPublishRequests = () =>
|
||||
request<PersonalPublishRequestRow[]>({
|
||||
url: '/api/aihr/personal-assistant/admin/publish-requests',
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
export const reviewPersonalPublishRequest = (id: string, payload: { approved: boolean; comment: string }) =>
|
||||
request<PersonalPublishRequestRow>({
|
||||
url: `/api/aihr/personal-assistant/admin/publish-requests/${encodeURIComponent(id)}/review`,
|
||||
method: 'post',
|
||||
data: payload
|
||||
});
|
||||
@@ -59,6 +59,31 @@
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel publish-review-panel">
|
||||
<div class="panel-title compact">
|
||||
<div>
|
||||
<h2>个人知识入库审核</h2>
|
||||
<p>只审核元数据与脱敏预览;批准后生成脱敏企业副本,不转移个人资料。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="publishLoading" size="small" @click="loadPublishRequests">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="publishLoading" :data="publishRequests" size="small">
|
||||
<el-table-column prop="title" label="资料标题" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="sourceType" label="来源" width="90" />
|
||||
<el-table-column prop="targetScope" label="发布范围" width="160" />
|
||||
<el-table-column prop="reason" label="申请原因" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="sanitizedPreview" label="脱敏预览" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="申请时间" width="170" />
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="reviewPublish(row, true)">批准</el-button>
|
||||
<el-button link @click="reviewPublish(row, false)">驳回</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!publishLoading && publishRequests.length === 0" description="暂无待审核申请" :image-size="72" />
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article v-for="metric in displayOverview.metrics" :key="metric.key" class="metric-card" :class="`metric-${metric.type}`">
|
||||
<div class="metric-icon">
|
||||
@@ -282,6 +307,7 @@ import {
|
||||
type ProcessingOverview
|
||||
} from '@/api/aihr/processing';
|
||||
import { uploadKnowledgeDocAsync, listUploadItems, retryUploadItem, type UploadItem } from '@/api/aihr/sop';
|
||||
import { listPersonalPublishRequests, reviewPersonalPublishRequest, type PersonalPublishRequestRow } from '@/api/aihr/personal';
|
||||
|
||||
const acceptTypes = '.txt,.md,.markdown,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.webp,.bmp,.mp4,.mov,.avi,.mkv,.webm,.m4v';
|
||||
const autoCategory = '__auto__';
|
||||
@@ -298,6 +324,8 @@ const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const serverImporting = ref(false);
|
||||
const importTaskLoading = ref(false);
|
||||
const publishLoading = ref(false);
|
||||
const publishRequests = ref<PersonalPublishRequestRow[]>([]);
|
||||
const overview = ref<ProcessingOverview | null>(null);
|
||||
const overviewFailed = ref(false);
|
||||
const importTasks = ref<LocalImportTask[]>([]);
|
||||
@@ -449,10 +477,40 @@ const filteredTasks = computed(() => displayOverview.value.tasks.filter((item) =
|
||||
const runningImport = computed(() => importTasks.value.some((item) => item.status === '导入中'));
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadOverview(), loadImportTasks()]);
|
||||
await Promise.all([loadOverview(), loadImportTasks(), loadPublishRequests()]);
|
||||
scheduleImportPoll();
|
||||
});
|
||||
|
||||
async function loadPublishRequests() {
|
||||
publishLoading.value = true;
|
||||
try {
|
||||
const response = await listPersonalPublishRequests();
|
||||
publishRequests.value = response.data ?? [];
|
||||
} catch {
|
||||
publishRequests.value = [];
|
||||
} finally {
|
||||
publishLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewPublish(row: PersonalPublishRequestRow, approved: boolean) {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(
|
||||
approved ? '批准后将生成脱敏企业副本,请填写审核意见。' : '请填写驳回原因。',
|
||||
approved ? '批准个人知识入库' : '驳回个人知识入库',
|
||||
{
|
||||
inputPlaceholder: approved ? '例如:脱敏后可入库' : '例如:内容不具备通用性',
|
||||
inputValidator: (value) => Boolean(value?.trim()) || '请填写审核意见'
|
||||
}
|
||||
);
|
||||
await reviewPersonalPublishRequest(row.id, { approved, comment: result.value.trim() });
|
||||
ElMessage.success(approved ? '已批准并生成企业知识副本' : '已驳回');
|
||||
await loadPublishRequests();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error('审核失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
clearImportPoll();
|
||||
clearBatchPoll();
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
<button v-if="item.sourceType === 'FILE' && item.status === 'READY'" class="secondary-button full-button" :disabled="busy" @click="download">下载原附件</button>
|
||||
</view>
|
||||
<view v-if="item.summary" class="panel employee-card"><text class="panel-title">内容摘要</text><text class="summary">{{ item.summary }}</text></view>
|
||||
<view v-if="item.status === 'READY'" class="panel employee-card publish-card">
|
||||
<text class="panel-title">沉淀企业知识</text>
|
||||
<text class="status-copy">提交后由 HR/运营审核;批准时只生成脱敏企业副本,个人资料仍归你所有。</text>
|
||||
<template v-if="publishRequest">
|
||||
<text class="publish-status">{{ publishStatusText(publishRequest.status) }}</text>
|
||||
<text v-if="publishRequest.reviewComment" class="meta">审核意见:{{ publishRequest.reviewComment }}</text>
|
||||
</template>
|
||||
<template v-if="!publishRequest || publishRequest.status === 'REJECTED'">
|
||||
<input v-model="publishReason" class="publish-input" maxlength="500" placeholder="说明为什么值得沉淀为企业知识" />
|
||||
<button class="primary-button full-button" :disabled="busy || !publishReason.trim()" @click="submitPublishRequest">申请沉淀企业知识</button>
|
||||
</template>
|
||||
</view>
|
||||
<view class="panel danger-panel">
|
||||
<text class="panel-title danger-title">删除资料</text>
|
||||
<text class="danger-copy">删除后,附件、解析正文和搜索索引会清除,且无法恢复。</text>
|
||||
@@ -38,22 +50,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app';
|
||||
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus } from '@/types/api';
|
||||
import { deletePersonalItem, downloadPersonalItem, loadPersonalItem, retryPersonalItem, retryPersonalOcrPages } from '@/services/personal-assistant';
|
||||
import type { PersonalItem, PersonalItemSourceType, PersonalItemStatus, PersonalPublishRequest } from '@/types/api';
|
||||
import { deletePersonalItem, downloadPersonalItem, loadPersonalItem, loadPersonalPublishRequests, requestPersonalPublish, retryPersonalItem, retryPersonalOcrPages } from '@/services/personal-assistant';
|
||||
import { normalizeBackendId } from '@/services/personal-assistant-id';
|
||||
import { isOcrActive, ocrProgressPercent, ocrProgressText } from '@/services/personal-assistant-ocr';
|
||||
|
||||
const itemId = ref(''); const item = ref<PersonalItem>(); const loading = ref(false); const busy = ref(false); const message = ref('');
|
||||
const publishRequest = ref<PersonalPublishRequest>(); const publishReason = ref('');
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const statusText = (status: PersonalItemStatus) => ({ QUEUED: '等待处理', PARSING: '解析中', READY: '可检索', FAILED: '解析失败', DELETING: '删除中', DELETED: '已删除' })[status];
|
||||
const statusDescription = (status: PersonalItemStatus) => ({ QUEUED: '已进入解析队列。', PARSING: '正在提取正文和建立搜索索引。', READY: '已完成解析,可用于个人助理问答。', FAILED: '本次解析失败,可查看公开原因后重试。', DELETING: '正在清理附件、正文和索引。', DELETED: '资料已删除。' })[status];
|
||||
const sourceText = (source: PersonalItemSourceType) => ({ TEXT: '文字', FILE: '文件', URL: '外部网页' })[source];
|
||||
const stopPolling = () => { if (pollTimer) clearTimeout(pollTimer); pollTimer = undefined; };
|
||||
const schedulePolling = () => { stopPolling(); if (item.value && (['QUEUED', 'PARSING'].includes(item.value.status) || isOcrActive(item.value.ocr))) pollTimer = setTimeout(() => void load(true), 2000); };
|
||||
const load = async (silent = false) => { if (!itemId.value) return; if (!silent) loading.value = true; message.value = ''; try { item.value = await loadPersonalItem(itemId.value); schedulePolling(); } catch (error) { message.value = error instanceof Error ? error.message : '资料不存在或无权访问'; stopPolling(); } finally { if (!silent) loading.value = false; } };
|
||||
const load = async (silent = false) => { if (!itemId.value) return; if (!silent) loading.value = true; message.value = ''; try { item.value = await loadPersonalItem(itemId.value); const requests = await loadPersonalPublishRequests(); publishRequest.value = requests.find((request) => request.itemId === itemId.value); schedulePolling(); } catch (error) { message.value = error instanceof Error ? error.message : '资料不存在或无权访问'; stopPolling(); } finally { if (!silent) loading.value = false; } };
|
||||
const retry = async () => { busy.value = true; message.value = ''; try { item.value = await retryPersonalItem(itemId.value); } catch (error) { message.value = error instanceof Error ? error.message : '重试失败'; } finally { busy.value = false; } };
|
||||
const retryOcrPages = async () => { busy.value = true; message.value = ''; try { item.value = await retryPersonalOcrPages(itemId.value); schedulePolling(); } catch (error) { message.value = error instanceof Error ? error.message : '失败页重试失败'; } finally { busy.value = false; } };
|
||||
const download = async () => { busy.value = true; try { const result = await downloadPersonalItem(itemId.value); if (!result.url) throw new Error('附件下载地址不可用'); if (typeof window !== 'undefined') window.open(result.url, '_blank', 'noopener,noreferrer'); else uni.downloadFile({ url: result.url }); } catch (error) { message.value = error instanceof Error ? error.message : '下载失败'; } finally { busy.value = false; } };
|
||||
const publishStatusText = (status: PersonalPublishRequest['status']) => ({ PENDING: '待审核', APPROVED: '已批准并生成企业知识副本', REJECTED: '已驳回' })[status];
|
||||
const submitPublishRequest = async () => { const reason = publishReason.value.trim(); if (!reason || busy.value) return; busy.value = true; message.value = ''; try { publishRequest.value = await requestPersonalPublish(itemId.value, reason); publishReason.value = ''; message.value = '申请已提交,等待 HR/运营审核'; } catch (error) { message.value = error instanceof Error ? error.message : '申请提交失败'; } finally { busy.value = false; } };
|
||||
const confirmDelete = () => uni.showModal({ title: '确认删除资料', content: '附件、解析正文和搜索索引会清除,删除后无法恢复。', confirmText: '确认删除', confirmColor: '#e70014', success: (result) => { if (result.confirm) void remove(); } });
|
||||
const remove = async () => { busy.value = true; message.value = ''; try { await deletePersonalItem(itemId.value); uni.redirectTo({ url: '/pages/user/assistant/library' }); } catch (error) { message.value = error instanceof Error ? error.message : '删除失败,请重试'; } finally { busy.value = false; } };
|
||||
const goBack = () => uni.navigateBack();
|
||||
@@ -70,4 +85,5 @@ onUnload(stopPolling);
|
||||
|
||||
<style scoped>
|
||||
.item-page { overflow-x: hidden; }.detail-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.source-badge, .status-badge { padding: 4px 8px; border-radius: var(--radius); background: #eef3f9; color: var(--knowledge); font-size: 12px; }.status-badge.ready { color: var(--success); }.status-badge.failed, .danger-title { color: #b42318; }.item-title, .meta, .summary, .status-copy, .danger-copy { display: block; }.item-title { margin-top: 14px; font-size: 20px; font-weight: 900; }.meta { margin-top: 8px; color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }.status-copy, .summary, .danger-copy { margin-top: 10px; line-height: 1.65; }.summary { white-space: pre-wrap; }.full-button { width: 100%; margin-top: 12px; }.ocr-progress { margin-top: 14px; }.ocr-progress-head { display: flex; justify-content: space-between; gap: 12px; color: #344054; font-size: 13px; }.ocr-progress-track { height: 8px; margin-top: 8px; overflow: hidden; border-radius: 4px; background: #eef1f5; }.ocr-progress-value { height: 100%; border-radius: 4px; background: #e70014; transition: width 180ms ease; }.ocr-failed-pages { display: block; margin-top: 8px; color: #b54708; font-size: 12px; }.danger-panel { border-color: #f5c2c7; }.danger-button { color: #b42318; }
|
||||
.publish-input { width: 100%; min-height: 44px; margin-top: 12px; padding: 0 12px; border: 1px solid var(--line); background: #fff; }.publish-status { display: block; margin-top: 12px; color: var(--brand); font-weight: 800; }
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PersonalExportOutlineSection,
|
||||
PersonalKnowledgeScope,
|
||||
PersonalPage,
|
||||
PersonalPublishRequest,
|
||||
PersonalSession,
|
||||
PersonalSessionDetail,
|
||||
PersonalSpace
|
||||
@@ -203,3 +204,14 @@ export const downloadPersonalExport = (id: string) => apiRequest<{ url: string;
|
||||
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/download-url`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
export const requestPersonalPublish = (itemId: string, reason: string) => apiRequest<PersonalPublishRequest>({
|
||||
url: `/api/aihr/personal-assistant/items/${encodeURIComponent(itemId)}/publish-requests`,
|
||||
method: 'POST',
|
||||
data: { targetScope: 'POSITION:生活顾问', reason }
|
||||
});
|
||||
|
||||
export const loadPersonalPublishRequests = () => apiRequest<PersonalPublishRequest[]>({
|
||||
url: '/api/aihr/personal-assistant/publish-requests',
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
@@ -163,6 +163,20 @@ export interface PersonalExportOutline {
|
||||
ossId?: string;
|
||||
}
|
||||
|
||||
export interface PersonalPublishRequest {
|
||||
id: string;
|
||||
itemId: string;
|
||||
title: string;
|
||||
sourceType: PersonalItemSourceType;
|
||||
targetScope: string;
|
||||
reason: string;
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
reviewerUserId?: string;
|
||||
reviewComment?: string;
|
||||
publishedKnowledgeId?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface PersonalSpace {
|
||||
spaceId: string;
|
||||
status: string;
|
||||
|
||||
@@ -64,6 +64,8 @@ test('个人助理先生成可编辑大纲并在确认后冻结来源', async ()
|
||||
assert.match(client, /enqueuePersonalPpt/);
|
||||
assert.match(client, /loadPersonalExport/);
|
||||
assert.match(client, /downloadPersonalExport/);
|
||||
assert.match(client, /requestPersonalPublish/);
|
||||
assert.match(client, /loadPersonalPublishRequests/);
|
||||
assert.match(page, /生成 PPT/);
|
||||
assert.match(page, /下载 PPT/);
|
||||
assert.match(page, /生成汇报提纲/);
|
||||
|
||||
Reference in New Issue
Block a user