feat(personal): export reviewed outlines to pptx
This commit is contained in:
@@ -60,6 +60,12 @@
|
|||||||
<version>3.0.5</version>
|
<version>3.0.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>5.4.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
+12
@@ -7,6 +7,7 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.DownloadUrlResponse
|
|||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineResponse;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||||
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportPptRequest;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemCreatedResponse;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ItemResponse;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.OcrProgressResponse;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.OcrProgressResponse;
|
||||||
@@ -179,6 +180,17 @@ public class PersonalAssistantController {
|
|||||||
return R.ok();
|
return R.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/exports/{id}/pptx")
|
||||||
|
public R<Void> enqueuePpt(@PathVariable long id, @RequestBody(required = false) ExportPptRequest request) {
|
||||||
|
exportService.enqueuePpt(owner(), id, request == null ? null : request.templateCode());
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/exports/{id}/download-url")
|
||||||
|
public R<DownloadUrlResponse> exportDownloadUrl(@PathVariable long id) {
|
||||||
|
return R.ok(exportService.downloadUrl(owner(), id));
|
||||||
|
}
|
||||||
|
|
||||||
private PersonalOwner owner() {
|
private PersonalOwner owner() {
|
||||||
return ownerProvider.current();
|
return ownerProvider.current();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -99,6 +99,9 @@ public final class PersonalAssistantDto {
|
|||||||
public record ExportOutlineUpdateRequest(String title, List<ExportOutlineSection> sections) {
|
public record ExportOutlineUpdateRequest(String title, List<ExportOutlineSection> sections) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record ExportPptRequest(String templateCode) {
|
||||||
|
}
|
||||||
|
|
||||||
public record ExportOutlineSource(String domain, String sourceId, Long itemId, String title, String excerpt) {
|
public record ExportOutlineSource(String domain, String sourceId, Long itemId, String title, String excerpt) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+265
-4
@@ -9,12 +9,33 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineRespon
|
|||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSection;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSection;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSource;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSource;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||||
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.DownloadUrlResponse;
|
||||||
|
import org.dromara.aihr.personal.support.PersonalKnowledgeProperties;
|
||||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||||
import org.dromara.common.core.exception.ServiceException;
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.common.oss.core.OssClient;
|
||||||
|
import org.dromara.common.oss.entity.UploadResult;
|
||||||
|
import org.dromara.common.oss.enums.AccessPolicyType;
|
||||||
|
import org.dromara.common.oss.factory.OssFactory;
|
||||||
|
import org.apache.poi.sl.usermodel.TextParagraph.TextAlign;
|
||||||
|
import org.apache.poi.xslf.usermodel.XMLSlideShow;
|
||||||
|
import org.apache.poi.xslf.usermodel.XSLFSlide;
|
||||||
|
import org.apache.poi.xslf.usermodel.XSLFTextBox;
|
||||||
|
import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
|
||||||
|
import org.apache.poi.xslf.usermodel.XSLFTextRun;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.geom.Rectangle2D;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -27,21 +48,31 @@ public class PersonalExportService {
|
|||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final LongSupplier idSupplier;
|
private final LongSupplier idSupplier;
|
||||||
|
private final ExportStorage storage;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
|
public PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
this(jdbc, objectMapper, IdWorker::getId);
|
PersonalKnowledgeProperties properties) {
|
||||||
|
this(jdbc, objectMapper, IdWorker::getId,
|
||||||
|
new DefaultExportStorage(jdbc, properties, PersonalExportService::ossClient));
|
||||||
}
|
}
|
||||||
|
|
||||||
private PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper, LongSupplier idSupplier) {
|
private PersonalExportService(JdbcTemplate jdbc, ObjectMapper objectMapper, LongSupplier idSupplier,
|
||||||
|
ExportStorage storage) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.idSupplier = idSupplier;
|
this.idSupplier = idSupplier;
|
||||||
|
this.storage = storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PersonalExportService forTest(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
public static PersonalExportService forTest(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
LongSupplier idSupplier) {
|
LongSupplier idSupplier) {
|
||||||
return new PersonalExportService(jdbc, objectMapper, idSupplier);
|
return new PersonalExportService(jdbc, objectMapper, idSupplier, new UnavailableExportStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PersonalExportService forTest(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||||
|
LongSupplier idSupplier, ExportStorage storage) {
|
||||||
|
return new PersonalExportService(jdbc, objectMapper, idSupplier, storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ExportOutlineResponse createOutline(PersonalOwner owner, ExportOutlineCreateRequest request) {
|
public ExportOutlineResponse createOutline(PersonalOwner owner, ExportOutlineCreateRequest request) {
|
||||||
@@ -115,6 +146,129 @@ public class PersonalExportService {
|
|||||||
if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void enqueuePpt(PersonalOwner owner, long exportId, String templateCode) {
|
||||||
|
ExportOutlineResponse outline = load(owner, exportId);
|
||||||
|
if (!outline.confirmed() || !("CONFIRMED".equals(outline.status()) || "FAILED".equals(outline.status()))) {
|
||||||
|
throw new ServiceException("PERSONAL_OUTLINE_NOT_CONFIRMED");
|
||||||
|
}
|
||||||
|
String template = clean(templateCode);
|
||||||
|
if (template.isEmpty()) template = "standard";
|
||||||
|
int changed = jdbc.update("""
|
||||||
|
update aihr_personal_export_task
|
||||||
|
set template_code = ?, status = 'PENDING', oss_id = null, error_message = null, update_time = now()
|
||||||
|
where binary tenant_id = binary ? and owner_user_id = ? and id = ?
|
||||||
|
and outline_confirmed = 1 and status in ('CONFIRMED','FAILED')
|
||||||
|
""", truncate(template, 50), owner.tenantId(), owner.userId(), exportId);
|
||||||
|
if (changed != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
||||||
|
}
|
||||||
|
|
||||||
|
public DownloadUrlResponse downloadUrl(PersonalOwner owner, long exportId) {
|
||||||
|
ExportOutlineResponse outline = load(owner, exportId);
|
||||||
|
if (!"READY".equals(outline.status()) || outline.ossId() == null) {
|
||||||
|
throw new ServiceException("PERSONAL_EXPORT_NOT_READY");
|
||||||
|
}
|
||||||
|
return storage.download(owner, exportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${aihr.personal.export-delay-ms:2000}", scheduler = "personalTaskScheduler")
|
||||||
|
public void processPending() {
|
||||||
|
List<Map<String, Object>> pending = jdbc.queryForList("""
|
||||||
|
select id, tenant_id, owner_user_id, outline_json
|
||||||
|
from aihr_personal_export_task
|
||||||
|
where outline_confirmed = 1 and status = 'PENDING'
|
||||||
|
order by update_time, id limit 5
|
||||||
|
""");
|
||||||
|
for (Map<String, Object> row : pending) process(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] renderPpt(ExportOutlineResponse outline) {
|
||||||
|
if (outline == null || !outline.confirmed()) throw new ServiceException("PERSONAL_OUTLINE_NOT_CONFIRMED");
|
||||||
|
try (XMLSlideShow ppt = new XMLSlideShow(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||||
|
ppt.setPageSize(new Dimension(960, 540));
|
||||||
|
XSLFSlide cover = ppt.createSlide();
|
||||||
|
addTitle(cover, outline.title(), 76, 150, 808, 100, 34, new Color(23, 31, 45));
|
||||||
|
addTitle(cover, "个人 AI 助理 · 汇报材料", 76, 270, 808, 50, 18, new Color(110, 119, 133));
|
||||||
|
for (ExportOutlineSection section : outline.sections()) {
|
||||||
|
XSLFSlide slide = ppt.createSlide();
|
||||||
|
addTitle(slide, section.title(), 64, 48, 832, 60, 26, new Color(23, 31, 45));
|
||||||
|
addBullets(slide, section.bullets(), 82, 130, 796, 340);
|
||||||
|
}
|
||||||
|
XSLFSlide sources = ppt.createSlide();
|
||||||
|
addTitle(sources, "资料来源", 64, 48, 832, 60, 26, new Color(23, 31, 45));
|
||||||
|
List<String> sourceLines = outline.sources().stream()
|
||||||
|
.map(source -> "[" + clean(source.domain()) + "] " + clean(source.title())
|
||||||
|
+ (clean(source.excerpt()).isEmpty() ? "" : " — " + clean(source.excerpt())))
|
||||||
|
.toList();
|
||||||
|
addBullets(sources, sourceLines, 82, 130, 796, 340);
|
||||||
|
ppt.write(output);
|
||||||
|
return output.toByteArray();
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new ServiceException("PERSONAL_EXPORT_RENDER_FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void process(Map<String, Object> row) {
|
||||||
|
long exportId = nullableLong(row.get("id")) == null ? 0 : nullableLong(row.get("id"));
|
||||||
|
long ownerId = nullableLong(row.get("owner_user_id")) == null ? 0 : nullableLong(row.get("owner_user_id"));
|
||||||
|
PersonalOwner owner = new PersonalOwner(clean(String.valueOf(row.get("tenant_id"))), ownerId, null);
|
||||||
|
int claimed = jdbc.update("""
|
||||||
|
update aihr_personal_export_task set status = 'RUNNING', update_time = now()
|
||||||
|
where id = ? and binary tenant_id = binary ? and owner_user_id = ? and status = 'PENDING'
|
||||||
|
""", exportId, owner.tenantId(), owner.userId());
|
||||||
|
if (claimed != 1) return;
|
||||||
|
try {
|
||||||
|
ExportOutlineResponse raw = objectMapper.readValue(String.valueOf(row.get("outline_json")),
|
||||||
|
ExportOutlineResponse.class);
|
||||||
|
ExportOutlineResponse confirmed = new ExportOutlineResponse(raw.id(), raw.sessionId(), raw.title(),
|
||||||
|
raw.sections(), raw.sources(), true, "RUNNING", null);
|
||||||
|
StoredExport stored = storage.store(owner, exportId, renderPpt(confirmed));
|
||||||
|
int completed = jdbc.update("""
|
||||||
|
update aihr_personal_export_task
|
||||||
|
set status = 'READY', oss_id = ?, error_message = null, update_time = now()
|
||||||
|
where id = ? and binary tenant_id = binary ? and owner_user_id = ? and status = 'RUNNING'
|
||||||
|
""", stored.ossId(), exportId, owner.tenantId(), owner.userId());
|
||||||
|
if (completed != 1) throw new ServiceException("PERSONAL_EXPORT_COMPLETE_FAILED");
|
||||||
|
} catch (Exception exception) {
|
||||||
|
jdbc.update("""
|
||||||
|
update aihr_personal_export_task
|
||||||
|
set status = 'FAILED', error_message = ?, update_time = now()
|
||||||
|
where id = ? and binary tenant_id = binary ? and owner_user_id = ? and status = 'RUNNING'
|
||||||
|
""", safeError(exception), exportId, owner.tenantId(), owner.userId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addTitle(XSLFSlide slide, String text, double x, double y, double width, double height,
|
||||||
|
double fontSize, Color color) {
|
||||||
|
XSLFTextBox box = slide.createTextBox();
|
||||||
|
box.setAnchor(new Rectangle2D.Double(x, y, width, height));
|
||||||
|
XSLFTextParagraph paragraph = box.addNewTextParagraph();
|
||||||
|
paragraph.setTextAlign(TextAlign.LEFT);
|
||||||
|
XSLFTextRun run = paragraph.addNewTextRun();
|
||||||
|
run.setText(clean(text));
|
||||||
|
run.setFontFamily("Microsoft YaHei");
|
||||||
|
run.setFontSize(fontSize);
|
||||||
|
run.setFontColor(color);
|
||||||
|
run.setBold(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addBullets(XSLFSlide slide, List<String> bullets, double x, double y,
|
||||||
|
double width, double height) {
|
||||||
|
XSLFTextBox box = slide.createTextBox();
|
||||||
|
box.setAnchor(new Rectangle2D.Double(x, y, width, height));
|
||||||
|
for (String bullet : bullets == null ? List.<String>of() : bullets) {
|
||||||
|
XSLFTextParagraph paragraph = box.addNewTextParagraph();
|
||||||
|
paragraph.setBullet(true);
|
||||||
|
paragraph.setLeftMargin(28D);
|
||||||
|
paragraph.setIndent(-18D);
|
||||||
|
paragraph.setSpaceAfter(10D);
|
||||||
|
XSLFTextRun run = paragraph.addNewTextRun();
|
||||||
|
run.setText(truncate(clean(bullet), 360));
|
||||||
|
run.setFontFamily("Microsoft YaHei");
|
||||||
|
run.setFontSize(19D);
|
||||||
|
run.setFontColor(new Color(55, 65, 81));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ExportOutlineResponse load(PersonalOwner owner, long exportId) {
|
private ExportOutlineResponse load(PersonalOwner owner, long exportId) {
|
||||||
requireOwner(owner);
|
requireOwner(owner);
|
||||||
if (exportId <= 0) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
if (exportId <= 0) throw new ServiceException("PERSONAL_EXPORT_NOT_FOUND");
|
||||||
@@ -221,4 +375,111 @@ public class PersonalExportService {
|
|||||||
private static String truncate(String value, int max) {
|
private static String truncate(String value, int max) {
|
||||||
return value.length() <= max ? value : value.substring(0, max);
|
return value.length() <= max ? value : value.substring(0, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String safeError(Exception exception) {
|
||||||
|
String message = clean(exception.getMessage());
|
||||||
|
return truncate(message.isEmpty() ? "PERSONAL_EXPORT_FAILED" : message, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OssClient ossClient(String key) {
|
||||||
|
return key == null || key.isBlank() ? OssFactory.instance() : OssFactory.instance(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ExportStorage {
|
||||||
|
StoredExport store(PersonalOwner owner, long exportId, byte[] bytes);
|
||||||
|
DownloadUrlResponse download(PersonalOwner owner, long exportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record StoredExport(long ossId) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface OssClientProvider {
|
||||||
|
OssClient get(String configKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class DefaultExportStorage implements ExportStorage {
|
||||||
|
private static final String PPTX_MIME =
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final PersonalKnowledgeProperties properties;
|
||||||
|
private final OssClientProvider clients;
|
||||||
|
|
||||||
|
private DefaultExportStorage(JdbcTemplate jdbc, PersonalKnowledgeProperties properties,
|
||||||
|
OssClientProvider clients) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
this.properties = properties;
|
||||||
|
this.clients = clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StoredExport store(PersonalOwner owner, long exportId, byte[] bytes) {
|
||||||
|
OssClient client = privateClient();
|
||||||
|
long ossId = IdWorker.getId();
|
||||||
|
String objectKey = "personal/" + owner.tenantId() + "/" + owner.userId()
|
||||||
|
+ "/exports/" + exportId + ".pptx";
|
||||||
|
UploadResult upload = client.upload(new ByteArrayInputStream(bytes), objectKey,
|
||||||
|
(long) bytes.length, PPTX_MIME);
|
||||||
|
try {
|
||||||
|
int inserted = jdbc.update("""
|
||||||
|
insert into sys_oss
|
||||||
|
(oss_id, tenant_id, file_name, original_name, file_suffix, url, ext1,
|
||||||
|
create_time, create_by, update_time, update_by, service)
|
||||||
|
values (?, ?, ?, ?, '.pptx', ?, '{"domain":"PERSONAL_EXPORT"}',
|
||||||
|
now(), ?, now(), ?, ?)
|
||||||
|
""", ossId, owner.tenantId(), objectKey, "个人工作汇报.pptx", upload.getUrl(),
|
||||||
|
owner.userId(), owner.userId(), client.getConfigKey());
|
||||||
|
if (inserted != 1) throw new ServiceException("PERSONAL_EXPORT_OSS_FAILED");
|
||||||
|
return new StoredExport(ossId);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
client.delete(objectKey);
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DownloadUrlResponse download(PersonalOwner owner, long exportId) {
|
||||||
|
List<Map<String, Object>> rows = jdbc.queryForList("""
|
||||||
|
select o.file_name, o.service
|
||||||
|
from aihr_personal_export_task e
|
||||||
|
join sys_oss o on o.oss_id = e.oss_id and binary o.tenant_id = binary e.tenant_id
|
||||||
|
and o.create_by = e.owner_user_id
|
||||||
|
where binary e.tenant_id = binary ? and e.owner_user_id = ? and e.id = ?
|
||||||
|
and e.status = 'READY' and e.oss_id is not null
|
||||||
|
limit 1
|
||||||
|
""", owner.tenantId(), owner.userId(), exportId);
|
||||||
|
if (rows.size() != 1) throw new ServiceException("PERSONAL_EXPORT_NOT_READY");
|
||||||
|
Map<String, Object> row = rows.get(0);
|
||||||
|
OssClient client = clients.get(clean(String.valueOf(row.get("service"))));
|
||||||
|
requirePrivate(client);
|
||||||
|
int minutes = Math.max(1, Math.min(60, properties.getDownloadUrlMinutes()));
|
||||||
|
return new DownloadUrlResponse(client.createPresignedGetUrl(
|
||||||
|
clean(String.valueOf(row.get("file_name"))), Duration.ofMinutes(minutes)),
|
||||||
|
LocalDateTime.now().plusMinutes(minutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private OssClient privateClient() {
|
||||||
|
OssClient client = clients.get(clean(properties.getOssConfigKey()));
|
||||||
|
requirePrivate(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requirePrivate(OssClient client) {
|
||||||
|
if (client == null || client.getAccessPolicy() != AccessPolicyType.PRIVATE) {
|
||||||
|
throw new ServiceException("PERSONAL_OSS_NOT_PRIVATE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class UnavailableExportStorage implements ExportStorage {
|
||||||
|
@Override
|
||||||
|
public StoredExport store(PersonalOwner owner, long exportId, byte[] bytes) {
|
||||||
|
throw new ServiceException("PERSONAL_EXPORT_STORAGE_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DownloadUrlResponse download(PersonalOwner owner, long exportId) {
|
||||||
|
throw new ServiceException("PERSONAL_EXPORT_STORAGE_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -6,6 +6,7 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.TextItemRequest;
|
|||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.UrlItemRequest;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.UrlItemRequest;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineCreateRequest;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||||
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportPptRequest;
|
||||||
import org.dromara.aihr.personal.service.PersonalAnswerService;
|
import org.dromara.aihr.personal.service.PersonalAnswerService;
|
||||||
import org.dromara.aihr.personal.service.PersonalCleanupService;
|
import org.dromara.aihr.personal.service.PersonalCleanupService;
|
||||||
import org.dromara.aihr.personal.service.PersonalIngestionService;
|
import org.dromara.aihr.personal.service.PersonalIngestionService;
|
||||||
@@ -75,8 +76,10 @@ class PersonalAssistantControllerTest {
|
|||||||
controller.outline(7L);
|
controller.outline(7L);
|
||||||
controller.updateOutline(7L, new ExportOutlineUpdateRequest("工作汇报", List.of()));
|
controller.updateOutline(7L, new ExportOutlineUpdateRequest("工作汇报", List.of()));
|
||||||
controller.confirmOutline(7L);
|
controller.confirmOutline(7L);
|
||||||
|
controller.enqueuePpt(7L, new ExportPptRequest("standard"));
|
||||||
|
controller.exportDownloadUrl(7L);
|
||||||
|
|
||||||
verify(owners, times(19)).current();
|
verify(owners, times(21)).current();
|
||||||
assertFalse(PersonalAssistantController.class.isAnnotationPresent(SaIgnore.class));
|
assertFalse(PersonalAssistantController.class.isAnnotationPresent(SaIgnore.class));
|
||||||
for (var method : PersonalAssistantController.class.getDeclaredMethods()) {
|
for (var method : PersonalAssistantController.class.getDeclaredMethods()) {
|
||||||
assertFalse(method.isAnnotationPresent(SaIgnore.class), method.getName());
|
assertFalse(method.isAnnotationPresent(SaIgnore.class), method.getName());
|
||||||
|
|||||||
+69
-1
@@ -6,12 +6,17 @@ import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineRespon
|
|||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSection;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSection;
|
||||||
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
import org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineUpdateRequest;
|
||||||
import org.dromara.aihr.personal.service.PersonalExportService;
|
import org.dromara.aihr.personal.service.PersonalExportService;
|
||||||
|
import org.dromara.aihr.personal.service.PersonalExportService.ExportStorage;
|
||||||
|
import org.dromara.aihr.personal.service.PersonalExportService.StoredExport;
|
||||||
import org.dromara.aihr.personal.support.PersonalOwner;
|
import org.dromara.aihr.personal.support.PersonalOwner;
|
||||||
|
import org.apache.poi.xslf.usermodel.XMLSlideShow;
|
||||||
|
import org.apache.poi.xslf.usermodel.XSLFTextShape;
|
||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.lang.reflect.Constructor;
|
import java.lang.reflect.Constructor;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -28,6 +33,7 @@ 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.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -39,7 +45,6 @@ class PersonalExportServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void productionConstructorIsExplicitlyAutowired() {
|
void productionConstructorIsExplicitlyAutowired() {
|
||||||
assertTrue(List.of(PersonalExportService.class.getDeclaredConstructors()).stream()
|
assertTrue(List.of(PersonalExportService.class.getDeclaredConstructors()).stream()
|
||||||
.filter(constructor -> constructor.getParameterCount() == 2)
|
|
||||||
.map(Constructor::getAnnotations)
|
.map(Constructor::getAnnotations)
|
||||||
.flatMap(Arrays::stream)
|
.flatMap(Arrays::stream)
|
||||||
.anyMatch(annotation -> annotation.annotationType() == Autowired.class));
|
.anyMatch(annotation -> annotation.annotationType() == Autowired.class));
|
||||||
@@ -136,4 +141,67 @@ class PersonalExportServiceTest {
|
|||||||
assertEquals("CONFIRMED", result.status());
|
assertEquals("CONFIRMED", result.status());
|
||||||
assertEquals(5566L, result.ossId());
|
assertEquals(5566L, result.ossId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pptRequiresConfirmedOutlineOwnedByCurrentUser() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
ExportStorage storage = mock(ExportStorage.class);
|
||||||
|
String stored = "{\"id\":9001,\"sessionId\":77,\"title\":\"保洁改进\",\"sections\":[{\"title\":\"核心结论\",\"bullets\":[\"明确标准\"]}],\"sources\":[{\"domain\":\"PERSONAL\",\"sourceId\":\"9\",\"itemId\":9,\"title\":\"保洁检查记录\",\"excerpt\":\"每日复查\"}],\"confirmed\":false,\"status\":\"DRAFT\",\"ossId\":null}";
|
||||||
|
when(jdbc.queryForList(contains("from aihr_personal_export_task"),
|
||||||
|
eq("000000"), eq(101L), eq(9001L))).thenReturn(List.of(Map.of(
|
||||||
|
"outline_json", stored, "outline_confirmed", 0, "status", "DRAFT", "session_id", 77L)));
|
||||||
|
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L,
|
||||||
|
storage);
|
||||||
|
|
||||||
|
assertThrows(RuntimeException.class, () -> service.enqueuePpt(OWNER, 9001L, "standard"));
|
||||||
|
|
||||||
|
verifyNoInteractions(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatedPptContainsTitleAndSourceSlide() throws Exception {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L,
|
||||||
|
mock(ExportStorage.class));
|
||||||
|
ExportOutlineResponse outline = new ExportOutlineResponse(9001L, 77L, "保洁管理改进建议",
|
||||||
|
List.of(new ExportOutlineSection("核心结论", List.of("统一检查标准", "明确整改责任人"))),
|
||||||
|
List.of(new org.dromara.aihr.personal.domain.PersonalAssistantDto.ExportOutlineSource(
|
||||||
|
"PERSONAL", "9", 9L, "保洁检查记录", "每日复查")), true, "CONFIRMED", null);
|
||||||
|
|
||||||
|
byte[] bytes = service.renderPpt(outline);
|
||||||
|
|
||||||
|
try (XMLSlideShow ppt = new XMLSlideShow(new ByteArrayInputStream(bytes))) {
|
||||||
|
String allText = ppt.getSlides().stream()
|
||||||
|
.flatMap(slide -> slide.getShapes().stream())
|
||||||
|
.filter(XSLFTextShape.class::isInstance)
|
||||||
|
.map(XSLFTextShape.class::cast)
|
||||||
|
.map(XSLFTextShape::getText)
|
||||||
|
.reduce("", (left, right) -> left + "\n" + right);
|
||||||
|
assertTrue(allText.contains("保洁管理改进建议"));
|
||||||
|
assertTrue(allText.contains("资料来源"));
|
||||||
|
assertTrue(allText.contains("保洁检查记录"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pendingExportIsRenderedStoredAndMarkedReady() {
|
||||||
|
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||||
|
ExportStorage storage = mock(ExportStorage.class);
|
||||||
|
String stored = "{\"id\":9001,\"sessionId\":77,\"title\":\"保洁改进\",\"sections\":[{\"title\":\"核心结论\",\"bullets\":[\"明确标准\"]}],\"sources\":[{\"domain\":\"PERSONAL\",\"sourceId\":\"9\",\"itemId\":9,\"title\":\"保洁检查记录\",\"excerpt\":\"每日复查\"}],\"confirmed\":false,\"status\":\"DRAFT\",\"ossId\":null}";
|
||||||
|
when(jdbc.queryForList(contains("status = 'PENDING'"))).thenReturn(List.of(Map.of(
|
||||||
|
"id", 9001L, "tenant_id", "000000", "owner_user_id", 101L, "outline_json", stored)));
|
||||||
|
when(jdbc.update(contains("status = 'RUNNING'"), eq(9001L), eq("000000"), eq(101L))).thenReturn(1);
|
||||||
|
PersonalOwner storedOwner = new PersonalOwner("000000", 101L, null);
|
||||||
|
when(storage.store(eq(storedOwner), eq(9001L), any(byte[].class))).thenReturn(new StoredExport(5566L));
|
||||||
|
when(jdbc.update(contains("status = 'READY'"), eq(5566L), eq(9001L),
|
||||||
|
eq("000000"), eq(101L))).thenReturn(1);
|
||||||
|
PersonalExportService service = PersonalExportService.forTest(jdbc, new ObjectMapper(), () -> 9999L,
|
||||||
|
storage);
|
||||||
|
|
||||||
|
service.processPending();
|
||||||
|
|
||||||
|
verify(storage).store(eq(storedOwner), eq(9001L), any(byte[].class));
|
||||||
|
verify(jdbc).update(contains("status = 'READY'"), eq(5566L), eq(9001L),
|
||||||
|
eq("000000"), eq(101L));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -15,6 +15,7 @@ import java.util.Set;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
@@ -31,7 +32,7 @@ class PersonalOwnerProviderTest {
|
|||||||
.filter(type -> type.getSimpleName().endsWith("Request"))
|
.filter(type -> type.getSimpleName().endsWith("Request"))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
assertEquals(4, requestTypes.size());
|
assertFalse(requestTypes.isEmpty());
|
||||||
requestTypes.forEach(type -> {
|
requestTypes.forEach(type -> {
|
||||||
var componentNames = Arrays.stream(type.getRecordComponents())
|
var componentNames = Arrays.stream(type.getRecordComponents())
|
||||||
.map(component -> component.getName())
|
.map(component -> component.getName())
|
||||||
|
|||||||
@@ -1244,7 +1244,7 @@ Expected: PASS;批准生成企业副本;个人 item owner 不变;员工可
|
|||||||
- Modify: mobile-uni/src/services/personal-assistant.ts
|
- Modify: mobile-uni/src/services/personal-assistant.ts
|
||||||
- Modify: mobile-uni/src/pages/user/assistant/index.vue
|
- Modify: mobile-uni/src/pages/user/assistant/index.vue
|
||||||
|
|
||||||
- [ ] **Step 1: 写“先大纲后 PPT”的失败测试**
|
- [x] **Step 1: 写“先大纲后 PPT”的失败测试**
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void pptRequiresConfirmedOutlineOwnedByCurrentUser() {
|
void pptRequiresConfirmedOutlineOwnedByCurrentUser() {
|
||||||
@@ -1265,7 +1265,7 @@ Expected: PASS;批准生成企业副本;个人 item owner 不变;员工可
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
- [ ] **Step 2: 运行测试确认失败**
|
- [x] **Step 2: 运行测试确认失败**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -1273,7 +1273,7 @@ Run:
|
|||||||
|
|
||||||
Expected: FAIL,导出服务和 Apache POI 类型不存在。
|
Expected: FAIL,导出服务和 Apache POI 类型不存在。
|
||||||
|
|
||||||
- [ ] **Step 3: 增加直接依赖和导出任务表**
|
- [x] **Step 3: 增加直接依赖和导出任务表**
|
||||||
|
|
||||||
在 ruoyi-aihr/pom.xml 增加:
|
在 ruoyi-aihr/pom.xml 增加:
|
||||||
|
|
||||||
@@ -1285,7 +1285,7 @@ Expected: FAIL,导出服务和 Apache POI 类型不存在。
|
|||||||
|
|
||||||
新增 aihr_personal_export_task:tenant_id、owner_user_id、session_id、outline_json、outline_confirmed、template_code、status、oss_id、error_message、create_time、update_time。
|
新增 aihr_personal_export_task:tenant_id、owner_user_id、session_id、outline_json、outline_confirmed、template_code、status、oss_id、error_message、create_time、update_time。
|
||||||
|
|
||||||
- [ ] **Step 4: 实现两阶段导出**
|
- [x] **Step 4: 实现两阶段导出**
|
||||||
|
|
||||||
1. POST /exports/outline:根据已授权引用生成结构化大纲。
|
1. POST /exports/outline:根据已授权引用生成结构化大纲。
|
||||||
2. 用户编辑并确认 outline。
|
2. 用户编辑并确认 outline。
|
||||||
@@ -1295,7 +1295,7 @@ Expected: FAIL,导出服务和 Apache POI 类型不存在。
|
|||||||
|
|
||||||
PPT 不嵌入无权访问的企业原文,只保留标题、受控摘录和来源标识。
|
PPT 不嵌入无权访问的企业原文,只保留标题、受控摘录和来源标识。
|
||||||
|
|
||||||
- [ ] **Step 5: 运行 P1 检查**
|
- [x] **Step 5: 运行 P1 检查**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -1305,7 +1305,7 @@ Run:
|
|||||||
|
|
||||||
Expected: PASS;未确认大纲不能生成;PPTX 可被 POI 重新打开;包含资料来源页。
|
Expected: PASS;未确认大纲不能生成;PPTX 可被 POI 重新打开;包含资料来源页。
|
||||||
|
|
||||||
- [ ] **Step 6: 提交**
|
- [x] **Step 6: 提交**
|
||||||
|
|
||||||
git add backend/ruoyi-modules/ruoyi-aihr/pom.xml 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/PersonalExportServiceTest.java mobile-uni/src/services/personal-assistant.ts mobile-uni/src/pages/user/assistant/index.vue
|
git add backend/ruoyi-modules/ruoyi-aihr/pom.xml 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/PersonalExportServiceTest.java mobile-uni/src/services/personal-assistant.ts mobile-uni/src/pages/user/assistant/index.vue
|
||||||
git commit -m "feat(personal): export reviewed outlines to pptx"
|
git commit -m "feat(personal): export reviewed outlines to pptx"
|
||||||
|
|||||||
@@ -82,15 +82,22 @@
|
|||||||
<button class="secondary-button" :disabled="outlineBusy" @click="saveOutline">保存修改</button>
|
<button class="secondary-button" :disabled="outlineBusy" @click="saveOutline">保存修改</button>
|
||||||
<button class="primary-button" :disabled="outlineBusy" @click="confirmOutline">确认大纲</button>
|
<button class="primary-button" :disabled="outlineBusy" @click="confirmOutline">确认大纲</button>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-else class="outline-actions export-actions">
|
||||||
|
<button v-if="outline.status === 'CONFIRMED' || outline.status === 'FAILED'" class="primary-button" :disabled="outlineBusy" @click="generatePpt">
|
||||||
|
{{ outline.status === 'FAILED' ? '重新生成 PPT' : '生成 PPT' }}
|
||||||
|
</button>
|
||||||
|
<button v-if="outline.status === 'PENDING' || outline.status === 'RUNNING'" class="secondary-button" disabled>正在生成 PPT</button>
|
||||||
|
<button v-if="outline.status === 'READY'" class="primary-button" :disabled="outlineBusy" @click="downloadPpt">下载 PPT</button>
|
||||||
|
</view>
|
||||||
<text v-if="outlineMessage" class="status-message">{{ outlineMessage }}</text>
|
<text v-if="outlineMessage" class="status-message">{{ outlineMessage }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onUnmounted, ref } from 'vue';
|
||||||
import type { PersonalAskResponse, PersonalCitation, PersonalExportOutline } from '@/types/api';
|
import type { PersonalAskResponse, PersonalCitation, PersonalExportOutline } from '@/types/api';
|
||||||
import { askPersonalAssistant, confirmPersonalOutline, createPersonalOutline, updatePersonalOutline, type KnowledgeScope } from '@/services/personal-assistant';
|
import { askPersonalAssistant, confirmPersonalOutline, createPersonalOutline, downloadPersonalExport, enqueuePersonalPpt, loadPersonalExport, updatePersonalOutline, type KnowledgeScope } from '@/services/personal-assistant';
|
||||||
import { personalItemRoute } from '@/services/personal-assistant-id';
|
import { personalItemRoute } from '@/services/personal-assistant-id';
|
||||||
|
|
||||||
const scopes = ref<KnowledgeScope[]>(['PERSONAL']);
|
const scopes = ref<KnowledgeScope[]>(['PERSONAL']);
|
||||||
@@ -102,6 +109,7 @@ const outline = ref<PersonalExportOutline>();
|
|||||||
const outlineBusy = ref(false);
|
const outlineBusy = ref(false);
|
||||||
const outlineMessage = ref('');
|
const outlineMessage = ref('');
|
||||||
let requestGeneration = 0;
|
let requestGeneration = 0;
|
||||||
|
let exportPollTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
|
const enterpriseEnabled = computed(() => scopes.value.includes('ENTERPRISE'));
|
||||||
|
|
||||||
const scopeKey = (value: KnowledgeScope[]) => value.join(',');
|
const scopeKey = (value: KnowledgeScope[]) => value.join(',');
|
||||||
@@ -110,6 +118,7 @@ const changeScope = () => {
|
|||||||
requestGeneration += 1;
|
requestGeneration += 1;
|
||||||
answer.value = undefined;
|
answer.value = undefined;
|
||||||
outline.value = undefined;
|
outline.value = undefined;
|
||||||
|
stopExportPolling();
|
||||||
message.value = '';
|
message.value = '';
|
||||||
scopes.value = enterpriseEnabled.value ? ['PERSONAL'] : ['PERSONAL', 'ENTERPRISE'];
|
scopes.value = enterpriseEnabled.value ? ['PERSONAL'] : ['PERSONAL', 'ENTERPRISE'];
|
||||||
};
|
};
|
||||||
@@ -122,6 +131,7 @@ const submitQuestion = async () => {
|
|||||||
asking.value = true;
|
asking.value = true;
|
||||||
answer.value = undefined;
|
answer.value = undefined;
|
||||||
outline.value = undefined;
|
outline.value = undefined;
|
||||||
|
stopExportPolling();
|
||||||
message.value = '';
|
message.value = '';
|
||||||
try {
|
try {
|
||||||
const response = await askPersonalAssistant({ queryText: question, scope: scopeSnapshot, outputFormat: 'ACTION_PLAN' });
|
const response = await askPersonalAssistant({ queryText: question, scope: scopeSnapshot, outputFormat: 'ACTION_PLAN' });
|
||||||
@@ -206,6 +216,74 @@ const confirmOutline = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopExportPolling = () => {
|
||||||
|
if (exportPollTimer) clearTimeout(exportPollTimer);
|
||||||
|
exportPollTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollExport = async (id: string, attempts = 0) => {
|
||||||
|
stopExportPolling();
|
||||||
|
try {
|
||||||
|
const latest = await loadPersonalExport(id);
|
||||||
|
if (!outline.value || outline.value.id !== id) return;
|
||||||
|
outline.value = latest;
|
||||||
|
if (latest.status === 'READY') {
|
||||||
|
outlineMessage.value = 'PPT 已生成,可下载 5 分钟短链';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (latest.status === 'FAILED') {
|
||||||
|
outlineMessage.value = 'PPT 生成失败,可重新生成';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempts >= 40) {
|
||||||
|
outlineMessage.value = 'PPT 仍在后台生成,请稍后重试';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exportPollTimer = setTimeout(() => void pollExport(id, attempts + 1), 1500);
|
||||||
|
} catch (error) {
|
||||||
|
outlineMessage.value = error instanceof Error ? error.message : 'PPT 状态查询失败';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePpt = async () => {
|
||||||
|
if (!outline.value?.confirmed || outlineBusy.value) return;
|
||||||
|
outlineBusy.value = true;
|
||||||
|
outlineMessage.value = '';
|
||||||
|
try {
|
||||||
|
await enqueuePersonalPpt(outline.value.id);
|
||||||
|
outline.value.status = 'PENDING';
|
||||||
|
outlineMessage.value = 'PPT 已进入后台生成队列';
|
||||||
|
void pollExport(outline.value.id);
|
||||||
|
} catch (error) {
|
||||||
|
outlineMessage.value = error instanceof Error ? error.message : 'PPT 生成请求失败';
|
||||||
|
} finally {
|
||||||
|
outlineBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadPpt = async () => {
|
||||||
|
if (!outline.value || outline.value.status !== 'READY' || outlineBusy.value) return;
|
||||||
|
outlineBusy.value = true;
|
||||||
|
try {
|
||||||
|
const download = await downloadPersonalExport(outline.value.id);
|
||||||
|
uni.downloadFile({
|
||||||
|
url: download.url,
|
||||||
|
success: (result) => {
|
||||||
|
if (result.statusCode >= 200 && result.statusCode < 300) {
|
||||||
|
uni.openDocument({ filePath: result.tempFilePath, showMenu: true });
|
||||||
|
} else {
|
||||||
|
outlineMessage.value = 'PPT 下载失败,请重试';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => { outlineMessage.value = 'PPT 下载失败,请重试'; }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
outlineMessage.value = error instanceof Error ? error.message : 'PPT 下载失败';
|
||||||
|
} finally {
|
||||||
|
outlineBusy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addOutlineSection = () => outline.value?.sections.push({ title: '', bullets: [''] });
|
const addOutlineSection = () => outline.value?.sections.push({ title: '', bullets: [''] });
|
||||||
const addOutlineBullet = (sectionIndex: number) => outline.value?.sections[sectionIndex]?.bullets.push('');
|
const addOutlineBullet = (sectionIndex: number) => outline.value?.sections[sectionIndex]?.bullets.push('');
|
||||||
|
|
||||||
@@ -223,6 +301,7 @@ const goBack = () => uni.navigateBack();
|
|||||||
const openLibrary = () => uni.navigateTo({ url: '/pages/user/assistant/library' });
|
const openLibrary = () => uni.navigateTo({ url: '/pages/user/assistant/library' });
|
||||||
const openCapture = () => uni.navigateTo({ url: '/pages/user/assistant/capture' });
|
const openCapture = () => uni.navigateTo({ url: '/pages/user/assistant/capture' });
|
||||||
const openSessions = () => uni.navigateTo({ url: '/pages/user/assistant/sessions' });
|
const openSessions = () => uni.navigateTo({ url: '/pages/user/assistant/sessions' });
|
||||||
|
onUnmounted(stopExportPolling);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -187,3 +187,19 @@ export const confirmPersonalOutline = (id: string) => apiRequest<void>({
|
|||||||
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/confirm`,
|
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/confirm`,
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const loadPersonalExport = (id: string) => apiRequest<PersonalExportOutline>({
|
||||||
|
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enqueuePersonalPpt = (id: string) => apiRequest<void>({
|
||||||
|
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/pptx`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { templateCode: 'standard' }
|
||||||
|
});
|
||||||
|
|
||||||
|
export const downloadPersonalExport = (id: string) => apiRequest<{ url: string; expiresAt?: string }>({
|
||||||
|
url: `/api/aihr/personal-assistant/exports/${encodeURIComponent(id)}/download-url`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ test('个人助理先生成可编辑大纲并在确认后冻结来源', async ()
|
|||||||
assert.match(client, /createPersonalOutline/);
|
assert.match(client, /createPersonalOutline/);
|
||||||
assert.match(client, /updatePersonalOutline/);
|
assert.match(client, /updatePersonalOutline/);
|
||||||
assert.match(client, /confirmPersonalOutline/);
|
assert.match(client, /confirmPersonalOutline/);
|
||||||
|
assert.match(client, /enqueuePersonalPpt/);
|
||||||
|
assert.match(client, /loadPersonalExport/);
|
||||||
|
assert.match(client, /downloadPersonalExport/);
|
||||||
|
assert.match(page, /生成 PPT/);
|
||||||
|
assert.match(page, /下载 PPT/);
|
||||||
assert.match(page, /生成汇报提纲/);
|
assert.match(page, /生成汇报提纲/);
|
||||||
assert.match(page, /确认大纲/);
|
assert.match(page, /确认大纲/);
|
||||||
assert.match(page, /资料来源不可编辑/);
|
assert.match(page, /资料来源不可编辑/);
|
||||||
|
|||||||
Reference in New Issue
Block a user