feat(aihr): close functional checklist gaps
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
"ComputedRef": true,
|
||||
"DirectiveBinding": true,
|
||||
"EffectScope": true,
|
||||
"ElLoading": true,
|
||||
"ElMessage": true,
|
||||
"ElMessageBox": true,
|
||||
"ElNotification": true,
|
||||
"ExtractDefaultPropTypes": true,
|
||||
"ExtractPropTypes": true,
|
||||
"ExtractPublicPropTypes": true,
|
||||
|
||||
@@ -8,6 +8,15 @@ type ApiResult<T> = {
|
||||
|
||||
export type BroadcastStatus = 'PUBLISHED' | 'WITHDRAWN';
|
||||
export type BroadcastInsightStatus = 'NOT_PROCESSED' | 'PENDING' | 'READY' | 'PARTIAL' | 'FAILED';
|
||||
export type BroadcastVisibilityMode = 'ALL' | 'TARGET_ONLY';
|
||||
|
||||
export type BroadcastTopicTag = {
|
||||
name: string;
|
||||
sourceType: 'AUTO' | 'MANUAL';
|
||||
evidenceQuote: string;
|
||||
};
|
||||
|
||||
export type BroadcastTopicTagRequest = Pick<BroadcastTopicTag, 'name' | 'evidenceQuote'>;
|
||||
|
||||
export type BroadcastTargetRequest = {
|
||||
projectCodes: string[];
|
||||
@@ -38,6 +47,8 @@ export type BroadcastAdminMessage = {
|
||||
withdrawnAt?: string;
|
||||
withdrawReason?: string;
|
||||
requiredRead: boolean;
|
||||
visibilityMode: BroadcastVisibilityMode;
|
||||
allowDownload: boolean;
|
||||
targetRuleCount: number;
|
||||
matchedRecipientCount: number;
|
||||
unmatchedTargetCount: number;
|
||||
@@ -51,7 +62,11 @@ export type BroadcastAttachment = {
|
||||
status: 'QUEUED' | 'PROCESSING' | 'READY' | 'FAILED';
|
||||
summary?: string;
|
||||
errorMessage?: string;
|
||||
viewUrl?: string;
|
||||
downloadUrl?: string;
|
||||
allowDownload: boolean;
|
||||
watermarkText?: string;
|
||||
topicTags: BroadcastTopicTag[];
|
||||
insightStatus?: BroadcastInsightStatus;
|
||||
perspectiveLabels?: string[];
|
||||
};
|
||||
@@ -71,6 +86,8 @@ export type PublishBroadcastRequest = {
|
||||
content: string;
|
||||
expectedTenantId: string;
|
||||
requiredRead: boolean;
|
||||
visibilityMode: BroadcastVisibilityMode;
|
||||
allowDownload: boolean;
|
||||
targets: BroadcastTargetRequest;
|
||||
attachmentId?: number;
|
||||
};
|
||||
@@ -119,6 +136,17 @@ export function getBroadcastAttachment(id: number): Promise<ApiResult<BroadcastA
|
||||
});
|
||||
}
|
||||
|
||||
export function updateBroadcastAttachmentTopicTags(
|
||||
id: number,
|
||||
data: { expectedTenantId: string; topicTags: BroadcastTopicTagRequest[] }
|
||||
): Promise<ApiResult<BroadcastAttachment>> {
|
||||
return request({
|
||||
url: `/api/aihr/broadcast/admin/attachments/${id}/topic-tags`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
export function publishBroadcast(data: PublishBroadcastRequest): Promise<ApiResult<{
|
||||
id: number;
|
||||
version: number;
|
||||
|
||||
@@ -24,6 +24,10 @@ export type CaseRecord = {
|
||||
time: string;
|
||||
owner: string;
|
||||
summary: string;
|
||||
knowledgeStatus?: 'CANDIDATE' | 'PUBLISHED' | 'OFFLINE' | string;
|
||||
sourceType?: string;
|
||||
applicablePositions?: string;
|
||||
contentVersion?: string;
|
||||
};
|
||||
|
||||
export type CaseDetailResponse = {
|
||||
@@ -40,6 +44,15 @@ export type CaseDetailResponse = {
|
||||
mediaOssId?: number | null;
|
||||
learningPoints: string[];
|
||||
supervisorComment: string;
|
||||
knowledgeStatus?: 'CANDIDATE' | 'PUBLISHED' | 'OFFLINE' | string;
|
||||
sourceType?: string;
|
||||
applicablePositions?: string;
|
||||
contentVersion?: string;
|
||||
effectiveFrom?: string;
|
||||
effectiveTo?: string;
|
||||
releaseOwner?: string;
|
||||
releaseNote?: string;
|
||||
retireReason?: string;
|
||||
};
|
||||
|
||||
export type CaseUploadResponse = {
|
||||
@@ -122,3 +135,33 @@ export function reviewKnowledgeCase(caseId: string, comment: string): Promise<Ap
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export type CasePublishRequest = {
|
||||
sourceType: string;
|
||||
applicablePositions: string;
|
||||
contentVersion: string;
|
||||
releaseOwner: string;
|
||||
releaseNote?: string;
|
||||
sourceAuthorized: boolean;
|
||||
deidentified: boolean;
|
||||
effectiveFrom: string;
|
||||
effectiveTo?: string;
|
||||
};
|
||||
|
||||
export function publishKnowledgeCase(caseId: string, data: CasePublishRequest): Promise<ApiResult<void>> {
|
||||
return request({
|
||||
url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/publish`,
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
export function retireKnowledgeCase(caseId: string, reason: string): Promise<ApiResult<void>> {
|
||||
return request({
|
||||
url: `/api/knowledge/case/records/${encodeURIComponent(caseId)}/retire`,
|
||||
method: 'post',
|
||||
data: { reason },
|
||||
headers: { repeatSubmit: false }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,9 +152,32 @@ export type PracticeQuestion = {
|
||||
contentHash: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
examFormat?: 'single' | 'multiple' | 'judge' | 'short' | '';
|
||||
examOptions?: string[];
|
||||
examAnswers?: string[];
|
||||
examExplanation?: string;
|
||||
examEnabled?: boolean;
|
||||
reviewStatus?: 'DRAFT' | 'APPROVED' | 'REJECTED';
|
||||
reviewedBy?: string;
|
||||
reviewedTime?: string;
|
||||
};
|
||||
|
||||
export type PracticeQuestionSaveRequest = Omit<PracticeQuestion, 'scenarioName' | 'contentVersion' | 'contentHash'>;
|
||||
export type PracticeQuestionSaveRequest = Pick<
|
||||
PracticeQuestion,
|
||||
| 'code'
|
||||
| 'scenarioCode'
|
||||
| 'positionCode'
|
||||
| 'type'
|
||||
| 'question'
|
||||
| 'referenceAnswer'
|
||||
| 'enabled'
|
||||
| 'sortOrder'
|
||||
| 'examFormat'
|
||||
| 'examOptions'
|
||||
| 'examAnswers'
|
||||
| 'examExplanation'
|
||||
| 'examEnabled'
|
||||
>;
|
||||
|
||||
export type ManagedPracticeAssignment = {
|
||||
id: number;
|
||||
@@ -190,6 +213,70 @@ export type ManagedPracticeAssignmentBatchResponse = {
|
||||
results: Array<{ extPartyId: string; traineeName: string; status: string; message: string }>;
|
||||
};
|
||||
|
||||
export type ManagedLearningTask = {
|
||||
id: number;
|
||||
extPartyId: string;
|
||||
traineeName: string;
|
||||
projectCode: string;
|
||||
positionCode: string;
|
||||
taskType: string;
|
||||
status: string;
|
||||
materialType: 'DOCUMENT' | 'VIDEO' | 'COURSE' | 'SOP';
|
||||
materialAttachmentId: number;
|
||||
materialTitle: string;
|
||||
materialVersion: string;
|
||||
progressPercent: number;
|
||||
completionRule: 'MANUAL_CONFIRM' | 'EXAM_PASS';
|
||||
examId?: number | null;
|
||||
examTitle?: string | null;
|
||||
recurrenceType: 'ONCE' | 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
||||
assignedTime: string;
|
||||
dueTime: string;
|
||||
completedTime?: string;
|
||||
assignBy: string;
|
||||
};
|
||||
|
||||
export type LearningMaterialOption = {
|
||||
attachmentId: number;
|
||||
title: string;
|
||||
fileType: string;
|
||||
materialVersion: string;
|
||||
updatedTime: string;
|
||||
};
|
||||
|
||||
export type LearningExamOption = {
|
||||
examId: number;
|
||||
title: string;
|
||||
projectCode: string;
|
||||
positionCode: string;
|
||||
dueDate: string;
|
||||
targetCount: number;
|
||||
};
|
||||
|
||||
export type ManagedLearningTaskBatchRequest = {
|
||||
requestId: string;
|
||||
targets: Array<{ extPartyId: string; traineeName?: string }>;
|
||||
projectCode: string;
|
||||
positionCode: string;
|
||||
taskType: string;
|
||||
materialType: ManagedLearningTask['materialType'];
|
||||
materialAttachmentId: number;
|
||||
completionRule: ManagedLearningTask['completionRule'];
|
||||
examId?: number | null;
|
||||
recurrenceType: ManagedLearningTask['recurrenceType'];
|
||||
startTime: string;
|
||||
dueTime: string;
|
||||
};
|
||||
|
||||
export type ManagedLearningTaskBatchResponse = {
|
||||
requestId: string;
|
||||
total: number;
|
||||
created: number;
|
||||
duplicate: number;
|
||||
failed: number;
|
||||
items: Array<{ extPartyId: string; traineeName: string; status: string; message: string }>;
|
||||
};
|
||||
|
||||
export type PracticePrepCardResponse = {
|
||||
keyPoints: string[];
|
||||
redLines: string[];
|
||||
@@ -328,6 +415,30 @@ export function updatePracticeQuestionEnabled(code: string, enabled: boolean): P
|
||||
return request({ url: `/api/train/practice/questions/${encodeURIComponent(code)}/enabled`, method: 'patch', data: { enabled } });
|
||||
}
|
||||
|
||||
export function reviewPracticeQuestion(code: string, decision: 'APPROVE' | 'REJECT', note = ''): Promise<ApiResult<PracticeQuestion>> {
|
||||
return request({
|
||||
url: `/api/train/practice/questions/${encodeURIComponent(code)}/review`,
|
||||
method: 'patch',
|
||||
data: { decision, note }
|
||||
});
|
||||
}
|
||||
|
||||
export function listManagedLearningTasks(params?: { status?: string; keyword?: string; limit?: number }): Promise<ApiResult<ManagedLearningTask[]>> {
|
||||
return request({ url: '/api/train/practice/onboard-tasks/manage', method: 'get', params });
|
||||
}
|
||||
|
||||
export function listLearningMaterials(): Promise<ApiResult<LearningMaterialOption[]>> {
|
||||
return request({ url: '/api/train/practice/onboard-tasks/manage/materials', method: 'get' });
|
||||
}
|
||||
|
||||
export function listLearningExams(): Promise<ApiResult<LearningExamOption[]>> {
|
||||
return request({ url: '/api/train/practice/onboard-tasks/manage/exams', method: 'get' });
|
||||
}
|
||||
|
||||
export function createManagedLearningTasks(data: ManagedLearningTaskBatchRequest): Promise<ApiResult<ManagedLearningTaskBatchResponse>> {
|
||||
return request({ url: '/api/train/practice/onboard-tasks/manage/batch', method: 'post', data, headers: { repeatSubmit: false } });
|
||||
}
|
||||
|
||||
export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise<ApiResult<null>> {
|
||||
return request({
|
||||
url: `/api/train/practice/scenarios/${id}/enabled`,
|
||||
|
||||
@@ -41,6 +41,8 @@ const pending: PendingBroadcastPublish = {
|
||||
title: '夏季服务标准通知',
|
||||
content: '请按最新标准执行。',
|
||||
requiredRead: true,
|
||||
visibilityMode: 'TARGET_ONLY',
|
||||
allowDownload: false,
|
||||
targets: {
|
||||
projectCodes: ['PRJ-FCW'],
|
||||
positionNames: ['物业管家'],
|
||||
@@ -49,6 +51,19 @@ const pending: PendingBroadcastPublish = {
|
||||
};
|
||||
|
||||
describe('pending broadcast publish storage', () => {
|
||||
it('defaults legacy delivery controls to public and downloadable', () => {
|
||||
const sessionStorage = new MemoryStorage();
|
||||
const storage = createPendingBroadcastStorage({ sessionStorage });
|
||||
const key = broadcastPendingStorageKey(pending.tenantId, 'operator-1');
|
||||
const { visibilityMode: _visibilityMode, allowDownload: _allowDownload, ...legacy } = pending;
|
||||
sessionStorage.setItem(key, JSON.stringify(legacy));
|
||||
|
||||
expect(storage.read('operator-1', pending.tenantId)).toMatchObject({
|
||||
visibilityMode: 'ALL',
|
||||
allowDownload: true
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a retryable publish only into session storage', () => {
|
||||
const sessionStorage = new MemoryStorage();
|
||||
const legacyLocalStorage = new MemoryStorage();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BroadcastTargetRequest } from '@/api/aihr/broadcast';
|
||||
import type { BroadcastTargetRequest, BroadcastVisibilityMode } from '@/api/aihr/broadcast';
|
||||
|
||||
export const pendingBroadcastStoragePrefix = 'aihr.broadcast.pending.v2';
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface PendingBroadcastPublish {
|
||||
title: string;
|
||||
content: string;
|
||||
requiredRead: boolean;
|
||||
visibilityMode: BroadcastVisibilityMode;
|
||||
allowDownload: boolean;
|
||||
targets: BroadcastTargetRequest;
|
||||
attachmentId?: number;
|
||||
}
|
||||
@@ -58,12 +60,16 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde
|
||||
const candidate = JSON.parse(raw) as Partial<PendingBroadcastPublish>;
|
||||
const targets = parseTargets(candidate.targets);
|
||||
const requiredRead = candidate.requiredRead === undefined ? false : candidate.requiredRead;
|
||||
const visibilityMode = candidate.visibilityMode === undefined ? 'ALL' : candidate.visibilityMode;
|
||||
const allowDownload = candidate.allowDownload === undefined ? true : candidate.allowDownload;
|
||||
if (
|
||||
typeof candidate.tenantId !== 'string'
|
||||
|| typeof candidate.requestId !== 'string'
|
||||
|| typeof candidate.title !== 'string'
|
||||
|| typeof candidate.content !== 'string'
|
||||
|| typeof requiredRead !== 'boolean'
|
||||
|| !['ALL', 'TARGET_ONLY'].includes(visibilityMode)
|
||||
|| typeof allowDownload !== 'boolean'
|
||||
|| !targets
|
||||
) {
|
||||
return undefined;
|
||||
@@ -74,6 +80,8 @@ const parsePendingPublish = (raw: string | null): PendingBroadcastPublish | unde
|
||||
title: candidate.title,
|
||||
content: candidate.content,
|
||||
requiredRead,
|
||||
visibilityMode,
|
||||
allowDownload,
|
||||
targets,
|
||||
attachmentId: Number.isSafeInteger(candidate.attachmentId) && Number(candidate.attachmentId) > 0
|
||||
? Number(candidate.attachmentId)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>帮道大喇叭</h1>
|
||||
<p>向当前租户全员公开发布公司通知;可额外定向提醒项目、岗位或职级人员,并设置必读。撤回会停止员工端展示,但保留发布记录。</p>
|
||||
<p>向当前租户发布公司通知,可选择全员公开或仅目标员工可见,并控制必读和文件下载。撤回会停止员工端展示,但保留发布记录。</p>
|
||||
</div>
|
||||
<el-button type="danger" icon="Plus" :disabled="publishing || publishPending || !scopeTenantId || !storageAvailable" @click="openPublish">发布消息</el-button>
|
||||
</section>
|
||||
@@ -42,9 +42,12 @@
|
||||
<template #default="{ row }">
|
||||
<div class="target-summary">
|
||||
<el-tag v-if="row.requiredRead" type="danger" size="small">必读</el-tag>
|
||||
<el-tag v-if="row.targetRuleCount" type="warning" size="small">定向提醒</el-tag>
|
||||
<el-tag :type="row.visibilityMode === 'TARGET_ONLY' ? 'danger' : 'info'" size="small">
|
||||
{{ row.visibilityMode === 'TARGET_ONLY' ? '仅目标可见' : '全员公开' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="row.targetRuleCount && row.visibilityMode !== 'TARGET_ONLY'" type="warning" size="small">定向提醒</el-tag>
|
||||
<span v-if="row.targetRuleCount" class="target-count">已匹配 {{ row.matchedRecipientCount }} 人<span v-if="row.unmatchedTargetCount">,待匹配 {{ row.unmatchedTargetCount }} 人</span></span>
|
||||
<span v-else class="muted">全员公开</span>
|
||||
<span v-else-if="row.visibilityMode === 'TARGET_ONLY'" class="target-count">尚无匹配员工</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -104,7 +107,9 @@
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="消息会立即向当前租户员工端公开,请确认内容不含敏感人员、薪酬或审批材料。"
|
||||
:title="form.visibilityMode === 'TARGET_ONLY'
|
||||
? '消息只对发布时匹配到的目标员工可见;非目标员工无法查看或推断消息存在。'
|
||||
: '消息会立即向当前租户员工端公开,请确认内容不含敏感人员、薪酬或审批材料。'"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="publishPending"
|
||||
@@ -178,6 +183,21 @@
|
||||
@click="refreshAttachment(attachment.id)"
|
||||
>刷新状态</el-button>
|
||||
</div>
|
||||
<div v-if="attachment?.status === 'READY'" class="topic-editor">
|
||||
<div class="topic-editor-head">
|
||||
<span>文件主题标签(仅用于展示和检索,不参与权限判断)</span>
|
||||
<el-button link type="primary" :disabled="publishing || publishPending" @click="openTopicEditor">人工修正</el-button>
|
||||
</div>
|
||||
<div v-if="attachment.topicTags.length" class="topic-tags">
|
||||
<el-tooltip v-for="tag in attachment.topicTags" :key="tag.name" :content="`${tag.sourceType === 'MANUAL' ? '人工' : '自动'}依据:${tag.evidenceQuote}`" placement="top">
|
||||
<el-tag size="small" :type="tag.sourceType === 'MANUAL' ? 'success' : 'info'">{{ tag.name }}</el-tag>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<span v-else class="muted">未识别到主题标签</span>
|
||||
</div>
|
||||
<el-checkbox v-if="attachment?.status === 'READY'" v-model="form.allowDownload" :disabled="publishing || publishPending">
|
||||
允许员工下载原文件(关闭后仅可在带动态水印的页面内阅读)
|
||||
</el-checkbox>
|
||||
<el-alert
|
||||
v-if="attachment?.status === 'FAILED'"
|
||||
type="error"
|
||||
@@ -191,13 +211,19 @@
|
||||
<el-form-item label="阅读要求">
|
||||
<el-checkbox v-model="form.requiredRead" :disabled="publishing || publishPending">设为必读(员工端会优先显示未读内容)</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item label="定向提醒(可选)">
|
||||
<el-form-item label="可见范围">
|
||||
<el-radio-group v-model="form.visibilityMode" :disabled="publishing || publishPending">
|
||||
<el-radio-button value="ALL">全员公开</el-radio-button>
|
||||
<el-radio-button value="TARGET_ONLY">仅目标可见</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="form.visibilityMode === 'TARGET_ONLY' ? '可见目标(必选)' : '定向提醒(可选)'">
|
||||
<el-alert
|
||||
class="targeting-note"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="不选择范围时仍是全员公开。选择多个值时,同一维度按“或”匹配,不同维度按“且”匹配;未匹配账号不会丢失公开消息。"
|
||||
:title="targetingHelp"
|
||||
/>
|
||||
<div class="target-selects">
|
||||
<el-select
|
||||
@@ -247,25 +273,44 @@
|
||||
<el-button type="danger" :loading="publishing" @click="submitPublish">{{ publishPending ? '重试发布' : '确认发布' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="topicEditorVisible" title="修正文件主题标签" width="620px" :close-on-click-modal="false">
|
||||
<el-alert class="publish-alert" type="info" :closable="false" show-icon title="每个标签必须填写文件中可逐字找到的原文依据。保存会替换当前全部标签。" />
|
||||
<div class="topic-edit-list">
|
||||
<div v-for="(tag, index) in topicTagDrafts" :key="index" class="topic-edit-row">
|
||||
<el-input v-model="tag.name" maxlength="30" placeholder="标签,如:安保" />
|
||||
<el-input v-model="tag.evidenceQuote" maxlength="200" placeholder="粘贴文件中的原文依据" />
|
||||
<el-button :icon="Delete" circle title="删除标签" @click="topicTagDrafts.splice(index, 1)" />
|
||||
</div>
|
||||
<el-button v-if="topicTagDrafts.length < 8" :icon="Plus" @click="topicTagDrafts.push({ name: '', evidenceQuote: '' })">添加标签</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="topicEditorVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="topicTagsSaving" @click="saveTopicTags">保存标签</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="ContentBroadcast" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { Delete, Plus, Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { FormInstance, UploadRequestOptions } from 'element-plus';
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
getBroadcastAttachment,
|
||||
listBroadcastAdminMessages,
|
||||
loadBroadcastTargetOptions,
|
||||
publishBroadcast,
|
||||
updateBroadcastAttachmentTopicTags,
|
||||
uploadBroadcastAttachment,
|
||||
withdrawBroadcast,
|
||||
type BroadcastAttachment,
|
||||
type BroadcastAdminMessage,
|
||||
type BroadcastTargetRequest,
|
||||
type BroadcastTargetOptions,
|
||||
type BroadcastTopicTagRequest,
|
||||
type BroadcastVisibilityMode,
|
||||
type BroadcastStatus
|
||||
} from '@/api/aihr/broadcast';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
@@ -291,6 +336,9 @@ const targetOptionsLoading = ref(false);
|
||||
const targetOptions = ref<BroadcastTargetOptions>({ projects: [], positionNames: [], positionLevels: [] });
|
||||
const attachment = ref<BroadcastAttachment>();
|
||||
const attachmentUploading = ref(false);
|
||||
const topicEditorVisible = ref(false);
|
||||
const topicTagsSaving = ref(false);
|
||||
const topicTagDrafts = ref<BroadcastTopicTagRequest[]>([]);
|
||||
let attachmentPollTimer: number | undefined;
|
||||
const form = reactive({
|
||||
requestId: '',
|
||||
@@ -298,6 +346,8 @@ const form = reactive({
|
||||
content: '',
|
||||
attachmentId: undefined as number | undefined,
|
||||
requiredRead: false,
|
||||
visibilityMode: 'ALL' as BroadcastVisibilityMode,
|
||||
allowDownload: true,
|
||||
targets: { projectCodes: [] as string[], positionNames: [] as string[], positionLevels: [] as string[] }
|
||||
});
|
||||
const userStore = useUserStore();
|
||||
@@ -309,6 +359,9 @@ const rules = {
|
||||
title: [{ required: true, whitespace: true, message: '请输入消息标题', trigger: 'blur' }],
|
||||
content: [{ required: true, whitespace: true, message: '请输入消息正文', trigger: 'blur' }]
|
||||
};
|
||||
const targetingHelp = computed(() => form.visibilityMode === 'TARGET_ONLY'
|
||||
? '必须至少选择一个目标。发布时会冻结匹配员工;同一维度按“或”匹配,不同维度按“且”匹配,非目标员工无法查看。'
|
||||
: '不选择范围时为全员公开;选择范围只用于优先提醒。同一维度按“或”匹配,不同维度按“且”匹配。');
|
||||
|
||||
const displayTime = (value?: string) => (value ? value.replace('T', ' ').slice(0, 16) : '—');
|
||||
const createRequestId = () => globalThis.crypto?.randomUUID?.() ?? `broadcast-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
@@ -337,6 +390,8 @@ const isValidPendingPublish = (candidate: Partial<PendingBroadcastPublish>): can
|
||||
typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 &&
|
||||
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000 &&
|
||||
typeof candidate.requiredRead === 'boolean' &&
|
||||
(candidate.visibilityMode === 'ALL' || candidate.visibilityMode === 'TARGET_ONLY') &&
|
||||
typeof candidate.allowDownload === 'boolean' &&
|
||||
(candidate.attachmentId === undefined || (Number.isSafeInteger(candidate.attachmentId) && Number(candidate.attachmentId) > 0)) &&
|
||||
isValidTargets(candidate.targets);
|
||||
|
||||
@@ -367,6 +422,8 @@ const persistPendingPublish = () => {
|
||||
content: form.content,
|
||||
attachmentId: form.attachmentId,
|
||||
requiredRead: form.requiredRead,
|
||||
visibilityMode: form.visibilityMode,
|
||||
allowDownload: form.allowDownload,
|
||||
targets: copyTargets(form.targets)
|
||||
};
|
||||
try {
|
||||
@@ -421,6 +478,8 @@ const applyPendingPublish = (candidate: PendingBroadcastPublish) => {
|
||||
form.content = candidate.content;
|
||||
form.attachmentId = candidate.attachmentId;
|
||||
form.requiredRead = candidate.requiredRead;
|
||||
form.visibilityMode = candidate.visibilityMode;
|
||||
form.allowDownload = candidate.allowDownload;
|
||||
form.targets.projectCodes = [...candidate.targets.projectCodes];
|
||||
form.targets.positionNames = [...candidate.targets.positionNames];
|
||||
form.targets.positionLevels = [...candidate.targets.positionLevels];
|
||||
@@ -440,6 +499,8 @@ const restorePendingPublish = () => {
|
||||
const resetTargetForm = () => {
|
||||
const targets = emptyTargets();
|
||||
form.requiredRead = false;
|
||||
form.visibilityMode = 'ALL';
|
||||
form.allowDownload = true;
|
||||
form.targets.projectCodes = targets.projectCodes;
|
||||
form.targets.positionNames = targets.positionNames;
|
||||
form.targets.positionLevels = targets.positionLevels;
|
||||
@@ -607,6 +668,12 @@ const submitPublish = async () => {
|
||||
ElMessage.warning('公司文件仍在提炼中,完成后才能发布。');
|
||||
return;
|
||||
}
|
||||
const hasTargets = form.targets.projectCodes.length > 0
|
||||
|| form.targets.positionNames.length > 0 || form.targets.positionLevels.length > 0;
|
||||
if (form.visibilityMode === 'TARGET_ONLY' && !hasTargets) {
|
||||
ElMessage.warning('仅目标可见时,必须至少选择一个项目、岗位或职级。');
|
||||
return;
|
||||
}
|
||||
form.title = form.title.trim();
|
||||
form.content = form.content.trim();
|
||||
publishPending.value = true;
|
||||
@@ -616,14 +683,16 @@ const submitPublish = async () => {
|
||||
}
|
||||
publishing.value = true;
|
||||
try {
|
||||
const publishedWithTargeting = form.targets.projectCodes.length > 0
|
||||
|| form.targets.positionNames.length > 0 || form.targets.positionLevels.length > 0;
|
||||
const publishedWithTargeting = hasTargets;
|
||||
const visibilityMode = form.visibilityMode;
|
||||
const response = await publishBroadcast({
|
||||
requestId: form.requestId,
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
expectedTenantId: pendingTenantId.value,
|
||||
requiredRead: form.requiredRead,
|
||||
visibilityMode,
|
||||
allowDownload: form.allowDownload,
|
||||
targets: copyTargets(form.targets),
|
||||
attachmentId: form.attachmentId
|
||||
});
|
||||
@@ -634,7 +703,9 @@ const submitPublish = async () => {
|
||||
publishVisible.value = false;
|
||||
const matchedRecipientCount = Number(response.data?.matchedRecipientCount) || 0;
|
||||
const unmatchedTargetCount = Number(response.data?.unmatchedTargetCount) || 0;
|
||||
ElMessage.success(publishedWithTargeting
|
||||
ElMessage.success(visibilityMode === 'TARGET_ONLY'
|
||||
? `消息已发布,仅 ${matchedRecipientCount} 名目标员工可见${unmatchedTargetCount ? `,另有 ${unmatchedTargetCount} 名待匹配` : ''}`
|
||||
: publishedWithTargeting
|
||||
? `消息已发布,已定向提醒 ${matchedRecipientCount} 名员工${unmatchedTargetCount ? `,另有 ${unmatchedTargetCount} 名待匹配` : ''}`
|
||||
: '消息已发布');
|
||||
pageNum.value = 1;
|
||||
@@ -726,7 +797,12 @@ const uploadAttachment = async (options: UploadRequestOptions) => {
|
||||
ElMessage.success('文件已上传,正在提炼内容');
|
||||
void refreshAttachment(response.data.id);
|
||||
} catch (error) {
|
||||
options.onError(error instanceof Error ? error : new Error('文件上传失败'));
|
||||
const uploadError = Object.assign(error instanceof Error ? error : new Error('文件上传失败'), {
|
||||
status: 0,
|
||||
method: 'POST',
|
||||
url: '/api/aihr/broadcast/admin/attachments'
|
||||
});
|
||||
options.onError(uploadError);
|
||||
ElMessage.error('公司文件上传失败');
|
||||
} finally {
|
||||
attachmentUploading.value = false;
|
||||
@@ -739,6 +815,42 @@ const clearAttachment = () => {
|
||||
form.attachmentId = undefined;
|
||||
};
|
||||
|
||||
const openTopicEditor = () => {
|
||||
if (!attachment.value || attachment.value.status !== 'READY') return;
|
||||
topicTagDrafts.value = attachment.value.topicTags.map(({ name, evidenceQuote }) => ({ name, evidenceQuote }));
|
||||
topicEditorVisible.value = true;
|
||||
};
|
||||
|
||||
const saveTopicTags = async () => {
|
||||
if (!attachment.value || !scopeTenantId.value || topicTagsSaving.value) return;
|
||||
const normalized = topicTagDrafts.value.map((tag) => ({
|
||||
name: tag.name.trim(),
|
||||
evidenceQuote: tag.evidenceQuote.trim()
|
||||
}));
|
||||
if (normalized.some((tag) => !tag.name || !tag.evidenceQuote)) {
|
||||
ElMessage.warning('每个主题标签都必须填写名称和原文依据。');
|
||||
return;
|
||||
}
|
||||
if (new Set(normalized.map((tag) => tag.name)).size !== normalized.length) {
|
||||
ElMessage.warning('主题标签不能重复。');
|
||||
return;
|
||||
}
|
||||
topicTagsSaving.value = true;
|
||||
try {
|
||||
const response = await updateBroadcastAttachmentTopicTags(attachment.value.id, {
|
||||
expectedTenantId: scopeTenantId.value,
|
||||
topicTags: normalized
|
||||
});
|
||||
attachment.value = response.data;
|
||||
topicEditorVisible.value = false;
|
||||
ElMessage.success('主题标签已按原文依据更新。');
|
||||
} catch {
|
||||
ElMessage.error('主题标签保存失败,请确认依据可在文件原文中逐字找到。');
|
||||
} finally {
|
||||
topicTagsSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadMessages();
|
||||
});
|
||||
@@ -854,6 +966,40 @@ onUnmounted(stopAttachmentPolling);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topic-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e4e7ec;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.topic-editor-head,
|
||||
.topic-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topic-editor-head {
|
||||
justify-content: space-between;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topic-edit-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topic-edit-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr) 34px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
@@ -942,5 +1088,14 @@ onUnmounted(stopAttachmentPolling);
|
||||
.target-selects {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topic-edit-row {
|
||||
grid-template-columns: 1fr 34px;
|
||||
|
||||
:deep(.el-input:nth-child(2)) {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -88,9 +88,41 @@
|
||||
<div v-if="organized" class="tag-row">
|
||||
<span v-for="tag in displayTags" :key="tag.text" class="status-pill" :class="tag.type">{{ tag.text }}</span>
|
||||
</div>
|
||||
<div v-if="reviewed" class="publish-form">
|
||||
<div class="publish-form-title">正式经验发布确认</div>
|
||||
<el-form label-position="top" size="small">
|
||||
<div class="publish-form-grid">
|
||||
<el-form-item label="来源类型">
|
||||
<el-input v-model="publishForm.sourceType" maxlength="40" />
|
||||
</el-form-item>
|
||||
<el-form-item label="适用岗位">
|
||||
<el-input v-model="publishForm.applicablePositions" maxlength="500" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容版本">
|
||||
<el-input v-model="publishForm.contentVersion" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发布负责人">
|
||||
<el-input v-model="publishForm.releaseOwner" maxlength="80" placeholder="填写业务负责人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="生效日期">
|
||||
<el-date-picker v-model="publishForm.effectiveFrom" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
<el-form-item label="失效日期(可选)">
|
||||
<el-date-picker v-model="publishForm.effectiveTo" type="date" value-format="YYYY-MM-DD" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="发布说明">
|
||||
<el-input v-model="publishForm.releaseNote" type="textarea" :rows="2" maxlength="1000" />
|
||||
</el-form-item>
|
||||
<div class="publish-checks">
|
||||
<el-checkbox v-model="publishForm.sourceAuthorized">已确认来源授权</el-checkbox>
|
||||
<el-checkbox v-model="publishForm.deidentified">已完成脱敏复核</el-checkbox>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<el-button :disabled="!organized" @click="submitReview">{{ reviewed ? '已送审' : '送审' }}</el-button>
|
||||
<el-button type="danger" :disabled="!organized" @click="storeCase">{{ stored ? '已入库' : '入库' }}</el-button>
|
||||
<el-button type="danger" :loading="publishSaving" :disabled="!reviewed || apiRecord?.status !== '已审核' || stored" @click="storeCase">{{ stored ? '已正式发布' : '正式发布' }}</el-button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -155,6 +187,7 @@
|
||||
<el-input v-model="supervisorComment" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="记录本案例可复用的带教建议" />
|
||||
<div class="detail-actions">
|
||||
<el-button type="danger" :loading="reviewSaving" :disabled="!caseDetail.caseId" @click="submitSupervisorReview">保存点评</el-button>
|
||||
<el-button v-if="caseDetail.knowledgeStatus === 'PUBLISHED'" :loading="retireSaving" @click="retireCase">下线经验</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="caseMediaUrl" class="detail-block">
|
||||
@@ -172,7 +205,7 @@
|
||||
|
||||
<script setup name="KnowledgeCases" lang="ts">
|
||||
import { DocumentChecked, FolderChecked, Microphone, Tickets } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
@@ -181,9 +214,12 @@ import {
|
||||
getCaseMedia,
|
||||
getCaseRecords,
|
||||
organizeKnowledgeCase,
|
||||
publishKnowledgeCase,
|
||||
retireKnowledgeCase,
|
||||
reviewKnowledgeCase,
|
||||
uploadCaseAudio,
|
||||
type CaseDetailResponse,
|
||||
type CasePublishRequest,
|
||||
type CaseRecord,
|
||||
type CaseSummaryItem,
|
||||
type CaseTag
|
||||
@@ -213,6 +249,19 @@ const caseMediaUrl = ref('');
|
||||
const caseMediaLoading = ref(false);
|
||||
const supervisorComment = ref('');
|
||||
const reviewSaving = ref(false);
|
||||
const publishSaving = ref(false);
|
||||
const retireSaving = ref(false);
|
||||
const publishForm = ref<CasePublishRequest>({
|
||||
sourceType: '优秀生活顾问访谈',
|
||||
applicablePositions: '生活顾问',
|
||||
contentVersion: 'experience-v1',
|
||||
releaseOwner: '',
|
||||
releaseNote: '',
|
||||
sourceAuthorized: false,
|
||||
deidentified: false,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 10),
|
||||
effectiveTo: ''
|
||||
});
|
||||
const projectExtOrgId = ref('');
|
||||
const projectOptions = ref<OrgProjectOption[]>([]);
|
||||
const projectsLoading = ref(false);
|
||||
@@ -363,6 +412,51 @@ async function submitSupervisorReview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function retireCase() {
|
||||
if (!caseDetail.value || retireSaving.value) return;
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入下线原因', '下线经验', {
|
||||
confirmButtonText: '确认下线',
|
||||
cancelButtonText: '取消',
|
||||
inputValidator: (input) => Boolean(input?.trim()) || '下线原因不能为空'
|
||||
});
|
||||
retireSaving.value = true;
|
||||
await retireKnowledgeCase(caseDetail.value.caseId, value.trim());
|
||||
caseDetail.value.knowledgeStatus = 'OFFLINE';
|
||||
caseDetail.value.status = '已下线';
|
||||
await loadCaseRecords();
|
||||
ElMessage.success('经验已下线,员工端不再召回');
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') {
|
||||
ElMessage.error('经验下线失败');
|
||||
}
|
||||
} finally {
|
||||
retireSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReview() {
|
||||
if (!organized.value || !caseId.value || reviewed.value) {
|
||||
if (!organized.value) ElMessage.warning('请先完成 AI 整理');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await curateKnowledgeCase({
|
||||
caseId: caseId.value,
|
||||
criteria: '优秀经验候选',
|
||||
limit: 1
|
||||
});
|
||||
apiRecord.value = data.records?.[0] ?? null;
|
||||
apiSampleHint.value = data.sampleHint;
|
||||
reviewed.value = true;
|
||||
stored.value = false;
|
||||
await loadCaseRecords();
|
||||
ElMessage.success('案例已进入候选池,等待业务审核和正式发布确认');
|
||||
} catch {
|
||||
ElMessage.error('案例送审失败,请检查当前账号权限和案例状态');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAudioPicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -448,40 +542,26 @@ async function organizeCase() {
|
||||
}
|
||||
}
|
||||
|
||||
function submitReview() {
|
||||
if (!organized.value) {
|
||||
ElMessage.warning('请先完成 AI 整理');
|
||||
return;
|
||||
}
|
||||
reviewed.value = true;
|
||||
ElMessage.success('案例稿已送审');
|
||||
}
|
||||
|
||||
async function storeCase() {
|
||||
if (!organized.value) {
|
||||
ElMessage.warning('请先完成 AI 整理');
|
||||
if (!reviewed.value || !caseId.value || publishSaving.value) {
|
||||
if (!reviewed.value) ElMessage.warning('请先送审并完成业务审核');
|
||||
return;
|
||||
}
|
||||
reviewed.value = true;
|
||||
stored.value = true;
|
||||
if (apiRecord.value?.status !== '已审核') {
|
||||
ElMessage.warning('案例仍在候选池,需业务审核通过后才能正式发布');
|
||||
return;
|
||||
}
|
||||
publishSaving.value = true;
|
||||
try {
|
||||
const { data } = await curateKnowledgeCase({
|
||||
caseId: caseId.value,
|
||||
criteria: '可复用培训案例',
|
||||
limit: 1
|
||||
});
|
||||
apiRecord.value = data.records?.[0] ?? null;
|
||||
apiSampleHint.value = data.sampleHint;
|
||||
if (apiRecord.value && !records.value.some((item) => item.title === apiRecord.value?.title)) {
|
||||
records.value.unshift(apiRecord.value);
|
||||
}
|
||||
await publishKnowledgeCase(caseId.value, publishForm.value);
|
||||
stored.value = true;
|
||||
apiSampleHint.value = '已正式发布;员工端仅可读取已发布经验';
|
||||
await loadCaseRecords();
|
||||
ElMessage.success('案例已由后端 API 入库,可用于训练复用');
|
||||
ElMessage.success('经验已正式发布,可受控用于训练复用');
|
||||
} catch {
|
||||
apiMode.value = 'error';
|
||||
apiRecord.value = null;
|
||||
apiSampleHint.value = '';
|
||||
ElMessage.error('后端案例入库 API 不可用');
|
||||
ElMessage.error('正式发布失败,请确认审核状态、来源授权和脱敏复核');
|
||||
} finally {
|
||||
publishSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,6 +741,41 @@ onMounted(() => {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.publish-form {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #f0d9aa;
|
||||
border-radius: 8px;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.publish-form-title {
|
||||
margin-bottom: 10px;
|
||||
color: #8a5a00;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.publish-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.publish-form :deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.publish-form :deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.publish-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pipeline-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -813,5 +928,9 @@ onMounted(() => {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.publish-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<el-select v-model="filters.position" clearable placeholder="岗位"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="filters.type" clearable placeholder="题目用途"><el-option label="每日三题" value="daily" /><el-option label="专项任务" value="special" /></el-select>
|
||||
<el-select v-model="filters.enabled" clearable placeholder="状态"><el-option label="已启用" :value="true" /><el-option label="已停用" :value="false" /></el-select>
|
||||
<el-select v-model="filters.reviewStatus" clearable placeholder="考试审核"><el-option label="草稿" value="DRAFT" /><el-option label="已审核" value="APPROVED" /><el-option label="已驳回" value="REJECTED" /></el-select>
|
||||
<el-button type="danger" :loading="loading" @click="load">查询</el-button>
|
||||
</section>
|
||||
|
||||
@@ -22,11 +23,15 @@
|
||||
<el-table-column prop="type" label="用途" width="96"><template #default="{ row }">{{ row.type === 'daily' ? '每日三题' : '专项任务' }}</template></el-table-column>
|
||||
<el-table-column prop="question" label="题干" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="contentVersion" label="版本" width="90" />
|
||||
<el-table-column label="考试用途" width="110"><template #default="{ row }"><el-tag :type="row.examEnabled ? 'warning' : 'info'">{{ row.examEnabled ? formatExamType(row.examFormat) : '未启用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="考试审核" width="100"><template #default="{ row }"><el-tag :type="reviewTagType(row.reviewStatus)">{{ reviewLabel(row.reviewStatus) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="toggle(row)">{{ row.enabled ? '停用' : '启用' }}</el-button>
|
||||
<el-button v-if="row.examEnabled && row.reviewStatus !== 'APPROVED'" type="success" link @click="review(row, 'APPROVE')">审核通过</el-button>
|
||||
<el-button v-if="row.reviewStatus !== 'REJECTED'" type="warning" link @click="review(row, 'REJECT')">驳回</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -39,6 +44,15 @@
|
||||
<el-col :span="12"><el-form-item label="岗位" required><el-select v-model="form.positionCode" class="full" @change="onPositionChange"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="用途" required><el-select v-model="form.type" class="full"><el-option label="每日三题" value="daily" /><el-option label="专项任务" value="special" /></el-select></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-divider content-position="left">考试题配置</el-divider>
|
||||
<el-form-item label="可用于审核题库随机组卷"><el-switch v-model="form.examEnabled" /></el-form-item>
|
||||
<template v-if="form.examEnabled">
|
||||
<el-form-item label="考试题型" required><el-select v-model="form.examFormat" class="full"><el-option label="单选题" value="single" /><el-option label="多选题" value="multiple" /><el-option label="判断题" value="judge" /><el-option label="简答题" value="short" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.examFormat !== 'short'" label="选项(每行一项)" required><el-input v-model="examOptionsText" type="textarea" :rows="4" /></el-form-item>
|
||||
<el-form-item label="正确答案(每行一项)" required><el-input v-model="examAnswersText" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="考试解析"><el-input v-model="form.examExplanation" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
<el-alert type="warning" :closable="false" show-icon title="新建、编辑或启停题目后审核状态会回到草稿,必须重新审核后才能随机组卷。" />
|
||||
</template>
|
||||
<el-form-item label="训练场景" required><el-select v-model="form.scenarioCode" filterable class="full"><el-option v-for="item in scenarioOptions" :key="item.id" :label="`${item.name}(${item.id})`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="题目编码"><el-input v-model="form.code" :disabled="editing" placeholder="留空自动生成;编码创建后不可修改" /></el-form-item>
|
||||
<el-form-item label="题干" required><el-input v-model="form.question" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
@@ -55,10 +69,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
listPracticeQuestions,
|
||||
listPracticeScenarios,
|
||||
reviewPracticeQuestion,
|
||||
savePracticeQuestion,
|
||||
updatePracticeQuestionEnabled,
|
||||
type PracticeQuestion,
|
||||
@@ -69,13 +84,16 @@ import {
|
||||
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const examOptionsText = ref('');
|
||||
const examAnswersText = ref('');
|
||||
const dialogVisible = ref(false);
|
||||
const editing = ref(false);
|
||||
const rows = ref<PracticeQuestion[]>([]);
|
||||
const scenarios = ref<PracticeScenarioResponse[]>([]);
|
||||
const filters = reactive<{ position: string; type: '' | 'daily' | 'special'; enabled: boolean | undefined }>({ position: '', type: '', enabled: undefined });
|
||||
const filters = reactive<{ position: string; type: '' | 'daily' | 'special'; enabled: boolean | undefined; reviewStatus: string }>({ position: '', type: '', enabled: undefined, reviewStatus: '' });
|
||||
const emptyForm = (): PracticeQuestionSaveRequest => ({
|
||||
code: '', scenarioCode: '', positionCode: '生活顾问', type: 'daily', question: '', referenceAnswer: '', enabled: true, sortOrder: 100
|
||||
code: '', scenarioCode: '', positionCode: '生活顾问', type: 'daily', question: '', referenceAnswer: '', enabled: true, sortOrder: 100,
|
||||
examFormat: '', examOptions: [], examAnswers: [], examExplanation: '', examEnabled: false
|
||||
});
|
||||
const form = reactive<PracticeQuestionSaveRequest>(emptyForm());
|
||||
const scenarioOptions = computed(() => scenarios.value.filter((item) => item.position === form.positionCode));
|
||||
@@ -83,6 +101,7 @@ const filteredRows = computed(() => rows.value.filter((item) =>
|
||||
(!filters.position || item.positionCode === filters.position) &&
|
||||
(!filters.type || item.type === filters.type) &&
|
||||
(filters.enabled === undefined || item.enabled === filters.enabled)
|
||||
&& (!filters.reviewStatus || item.reviewStatus === filters.reviewStatus)
|
||||
));
|
||||
|
||||
const load = async () => {
|
||||
@@ -102,7 +121,7 @@ const load = async () => {
|
||||
};
|
||||
|
||||
const resetForm = () => Object.assign(form, emptyForm());
|
||||
const openCreate = () => { editing.value = false; resetForm(); dialogVisible.value = true; };
|
||||
const openCreate = () => { editing.value = false; resetForm(); examOptionsText.value = ''; examAnswersText.value = ''; dialogVisible.value = true; };
|
||||
const openEdit = (item: PracticeQuestion) => {
|
||||
editing.value = true;
|
||||
Object.assign(form, {
|
||||
@@ -113,8 +132,15 @@ const openEdit = (item: PracticeQuestion) => {
|
||||
question: item.question,
|
||||
referenceAnswer: item.referenceAnswer,
|
||||
enabled: item.enabled,
|
||||
sortOrder: item.sortOrder
|
||||
sortOrder: item.sortOrder,
|
||||
examFormat: item.examFormat || '',
|
||||
examOptions: item.examOptions || [],
|
||||
examAnswers: item.examAnswers || [],
|
||||
examExplanation: item.examExplanation || '',
|
||||
examEnabled: Boolean(item.examEnabled)
|
||||
});
|
||||
examOptionsText.value = (item.examOptions || []).join('\n');
|
||||
examAnswersText.value = (item.examAnswers || []).join('\n');
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const onPositionChange = () => {
|
||||
@@ -125,14 +151,30 @@ const toggle = async (item: PracticeQuestion) => {
|
||||
item.enabled = !item.enabled;
|
||||
ElMessage.success(item.enabled ? '题目已启用' : '题目已停用');
|
||||
};
|
||||
const reviewLabel = (status?: string) => ({ APPROVED: '已审核', REJECTED: '已驳回', DRAFT: '草稿' }[status || 'DRAFT'] || '草稿');
|
||||
const reviewTagType = (status?: string) => status === 'APPROVED' ? 'success' : status === 'REJECTED' ? 'danger' : 'info';
|
||||
const formatExamType = (format?: string) => ({ single: '单选', multiple: '多选', judge: '判断', short: '简答' }[format || ''] || '考试题');
|
||||
const review = async (item: PracticeQuestion, decision: 'APPROVE' | 'REJECT') => {
|
||||
const action = decision === 'APPROVE' ? '审核通过' : '驳回';
|
||||
await ElMessageBox.confirm(`确认将“${item.question}”${action}?`, '考试题审核', { type: decision === 'APPROVE' ? 'success' : 'warning' });
|
||||
await reviewPracticeQuestion(item.code, decision);
|
||||
await load();
|
||||
ElMessage.success(`题目已${action}`);
|
||||
};
|
||||
const save = async () => {
|
||||
if (!form.scenarioCode || !form.question.trim() || !form.referenceAnswer.trim()) {
|
||||
ElMessage.warning('请补齐训练场景、题干和参考答案');
|
||||
return;
|
||||
}
|
||||
const examOptions = examOptionsText.value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
||||
const examAnswers = examAnswersText.value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean);
|
||||
if (form.examEnabled && (!form.examFormat || !examAnswers.length || (form.examFormat !== 'short' && examOptions.length < 2))) {
|
||||
ElMessage.warning('请补齐考试题型、选项和正确答案');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await savePracticeQuestion({ ...form, code: form.code.trim() });
|
||||
await savePracticeQuestion({ ...form, code: form.code.trim(), examOptions, examAnswers });
|
||||
dialogVisible.value = false;
|
||||
await load();
|
||||
ElMessage.success('题目已保存,后续派发会使用新版本');
|
||||
|
||||
@@ -2,99 +2,188 @@
|
||||
<div class="tasks-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>训练任务运营</h1>
|
||||
<p>统一查看自动/人工/重练任务,按组织名单批量派发专项,并可在开始训练前撤回。</p>
|
||||
<h1>任务运营</h1>
|
||||
<p>训练任务与学习任务分开管理;学习材料、考试资格和人员范围均由服务端复核。</p>
|
||||
</div>
|
||||
<el-button type="danger" @click="openAssign">批量派发专项</el-button>
|
||||
<el-button type="danger" @click="activeView === 'practice' ? openPracticeAssign() : openLearningAssign()">
|
||||
{{ activeView === 'practice' ? '批量派发专项训练' : '批量派发学习任务' }}
|
||||
</el-button>
|
||||
</section>
|
||||
|
||||
<section class="filter-card">
|
||||
<el-select v-model="filters.position" clearable placeholder="岗位"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="任务状态"><el-option label="待训练" value="待训练" /><el-option label="已完成" value="已完成" /><el-option label="已撤回" value="已撤回" /></el-select>
|
||||
<el-select v-model="filters.source" clearable placeholder="任务来源"><el-option label="每日三题" value="daily" /><el-option label="人工专项" value="manual" /><el-option label="低分重练" value="retry" /></el-select>
|
||||
<el-input v-model="filters.keyword" clearable placeholder="场景名称 / 员工ID" @keyup.enter="load" />
|
||||
<el-button type="danger" :loading="loading" @click="load">查询</el-button>
|
||||
</section>
|
||||
<el-radio-group v-model="activeView" class="view-switch" @change="loadCurrentView">
|
||||
<el-radio-button value="practice">训练任务</el-radio-button>
|
||||
<el-radio-button value="learning">学习任务</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<section class="metric-grid">
|
||||
<div><span>待训练</span><strong>{{ pendingCount }}</strong></div><div><span>已完成</span><strong>{{ completedCount }}</strong></div><div><span>已撤回</span><strong>{{ withdrawnCount }}</strong></div><div><span>当前列表</span><strong>{{ rows.length }}</strong></div>
|
||||
</section>
|
||||
<template v-if="activeView === 'practice'">
|
||||
<section class="filter-band practice-filter">
|
||||
<el-select v-model="practiceFilters.position" clearable placeholder="岗位"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="practiceFilters.status" clearable placeholder="任务状态"><el-option label="待训练" value="待训练" /><el-option label="已完成" value="已完成" /><el-option label="已撤回" value="已撤回" /></el-select>
|
||||
<el-select v-model="practiceFilters.source" clearable placeholder="任务来源"><el-option label="每日三题" value="daily" /><el-option label="人工专项" value="manual" /><el-option label="低分重练" value="retry" /></el-select>
|
||||
<el-input v-model="practiceFilters.keyword" clearable placeholder="场景名称 / 员工ID" @keyup.enter="loadPractice" />
|
||||
<el-button type="danger" :loading="loading" @click="loadPractice">查询</el-button>
|
||||
</section>
|
||||
|
||||
<section class="table-card">
|
||||
<el-table v-loading="loading" :data="rows" row-key="id">
|
||||
<el-table-column prop="traineeName" label="员工" width="112" /><el-table-column prop="extPartyId" label="员工ID" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="scenarioName" label="场景" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="source" label="来源" width="98"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
|
||||
<el-table-column prop="dueDate" label="截止日" width="112" /><el-table-column prop="status" label="状态" width="92"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="contentVersion" label="内容版本" width="96" /><el-table-column prop="reason" label="任务说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="96" fixed="right"><template #default="{ row }"><el-button v-if="row.status === '待训练' && row.source !== 'daily'" type="danger" link @click="withdraw(row)">撤回</el-button><span v-else class="muted">—</span></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<section class="metric-grid">
|
||||
<div><span>待训练</span><strong>{{ practiceRows.filter((item) => item.status === '待训练').length }}</strong></div>
|
||||
<div><span>已完成</span><strong>{{ practiceRows.filter((item) => item.status === '已完成').length }}</strong></div>
|
||||
<div><span>已撤回</span><strong>{{ practiceRows.filter((item) => item.status === '已撤回').length }}</strong></div>
|
||||
<div><span>当前列表</span><strong>{{ practiceRows.length }}</strong></div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="assignVisible" title="批量派发专项训练" width="900px" destroy-on-close>
|
||||
<section class="table-band">
|
||||
<el-table v-loading="loading" :data="practiceRows" row-key="id">
|
||||
<el-table-column prop="traineeName" label="员工" width="112" /><el-table-column prop="extPartyId" label="员工ID" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="scenarioName" label="场景" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="source" label="来源" width="98"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
|
||||
<el-table-column prop="dueDate" label="截止日" width="112" /><el-table-column prop="status" label="状态" width="92"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="contentVersion" label="内容版本" width="108" /><el-table-column prop="reason" label="任务说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="96" fixed="right"><template #default="{ row }"><el-button v-if="row.status === '待训练' && row.source !== 'daily'" type="danger" link @click="withdraw(row)">撤回</el-button><span v-else class="muted">-</span></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<section class="filter-band learning-filter">
|
||||
<el-select v-model="learningFilters.status" clearable placeholder="任务状态"><el-option label="待完成" value="待完成" /><el-option label="进行中" value="进行中" /><el-option label="已完成" value="已完成" /></el-select>
|
||||
<el-input v-model="learningFilters.keyword" clearable placeholder="任务 / 材料 / 员工ID" @keyup.enter="loadLearning" />
|
||||
<el-button type="danger" :loading="loading" @click="loadLearning">查询</el-button>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<div><span>待完成</span><strong>{{ learningRows.filter((item) => item.status === '待完成').length }}</strong></div>
|
||||
<div><span>进行中</span><strong>{{ learningRows.filter((item) => item.status === '进行中').length }}</strong></div>
|
||||
<div><span>已完成</span><strong>{{ learningRows.filter((item) => item.status === '已完成').length }}</strong></div>
|
||||
<div><span>考试通过型</span><strong>{{ learningRows.filter((item) => item.completionRule === 'EXAM_PASS').length }}</strong></div>
|
||||
</section>
|
||||
|
||||
<section class="table-band">
|
||||
<el-table v-loading="loading" :data="learningRows" row-key="id">
|
||||
<el-table-column prop="traineeName" label="员工" width="105" /><el-table-column prop="taskType" label="学习任务" min-width="145" show-overflow-tooltip />
|
||||
<el-table-column prop="materialTitle" label="材料" min-width="190"><template #default="{ row }"><div>{{ row.materialTitle }}</div><small>{{ materialTypeLabel(row.materialType) }} · {{ row.materialVersion }}</small></template></el-table-column>
|
||||
<el-table-column label="完成条件" min-width="150"><template #default="{ row }">{{ completionLabel(row.completionRule) }}<div v-if="row.examTitle" class="muted">{{ row.examTitle }}</div></template></el-table-column>
|
||||
<el-table-column label="周期" width="84"><template #default="{ row }">{{ recurrenceLabel(row.recurrenceType) }}</template></el-table-column>
|
||||
<el-table-column label="进度" width="125"><template #default="{ row }"><el-progress :percentage="row.progressPercent || 0" :stroke-width="8" /></template></el-table-column>
|
||||
<el-table-column prop="dueTime" label="截止时间" width="162" /><el-table-column prop="status" label="状态" width="92"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="practiceAssignVisible" title="批量派发专项训练" width="900px" destroy-on-close>
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="8"><el-form-item label="项目"><el-select v-model="projectCode" clearable filterable class="full" @change="loadPeople"><el-option v-for="item in projectOptions" :key="item.projectCode" :label="item.projectName || item.projectCode" :value="item.projectCode" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="岗位"><el-select v-model="assignmentPosition" class="full" @change="onAssignmentPositionChange"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="训练场景"><el-select v-model="assignmentScenarioId" class="full"><el-option v-for="item in assignmentScenarios" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="14"><el-col :span="12"><el-form-item label="截止日期"><el-date-picker v-model="dueDate" type="date" value-format="YYYY-MM-DD" class="full" :disabled-date="disablePast" /></el-form-item></el-col><el-col :span="12"><el-form-item label="任务说明"><el-input v-model="reason" maxlength="180" show-word-limit /></el-form-item></el-col></el-row>
|
||||
<el-form-item label="选择员工(最多 100 人)"><el-alert :title="`当前岗位可派 ${eligiblePeople.length} 人,已选择 ${selectedPeople.length} 人。组织岗位会按统一规则归类;服务端会再次核验在职状态、组织范围和岗位匹配。`" type="info" :closable="false" /><el-table v-loading="peopleLoading" :data="eligiblePeople" max-height="360" row-key="extPartyId" @selection-change="onSelectPeople"><el-table-column type="selection" width="48" /><el-table-column prop="personName" label="员工" min-width="130" /><el-table-column label="组织岗位(训练归类)" min-width="180"><template #default="{ row }">{{ row.positionName }}({{ canonicalPosition(row.positionName) }})</template></el-table-column><el-table-column prop="projectName" label="项目" min-width="160" /><el-table-column prop="extPartyId" label="员工ID" min-width="140" /></el-table></el-form-item>
|
||||
<el-row :gutter="14"><el-col :span="12"><el-form-item label="截止日期"><el-date-picker v-model="practiceDueDate" type="date" value-format="YYYY-MM-DD" class="full" :disabled-date="disablePast" /></el-form-item></el-col><el-col :span="12"><el-form-item label="任务说明"><el-input v-model="reason" maxlength="180" show-word-limit /></el-form-item></el-col></el-row>
|
||||
<people-picker :people-loading="peopleLoading" :eligible-people="eligiblePeople" :selected-count="selectedPeople.length" @selection-change="onSelectPeople" />
|
||||
</el-form>
|
||||
<template #footer><el-button @click="assignVisible = false">取消</el-button><el-button type="danger" :loading="assigning" @click="assign">确认派发</el-button></template>
|
||||
<template #footer><el-button @click="practiceAssignVisible = false">取消</el-button><el-button type="danger" :loading="assigning" @click="assignPractice">确认派发</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="learningAssignVisible" title="批量派发学习任务" width="960px" destroy-on-close>
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="8"><el-form-item label="项目" required><el-select v-model="projectCode" filterable class="full" @change="onLearningScopeChange"><el-option v-for="item in projectOptions" :key="item.projectCode" :label="item.projectName || item.projectCode" :value="item.projectCode" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="岗位" required><el-select v-model="assignmentPosition" class="full" @change="onLearningScopeChange"><el-option v-for="item in positions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="任务名称" required><el-input v-model="learningForm.taskType" maxlength="20" placeholder="如:岗前制度学习" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="8"><el-form-item label="材料类型" required><el-select v-model="learningForm.materialType" class="full"><el-option label="文档" value="DOCUMENT" /><el-option label="视频" value="VIDEO" /><el-option label="课程" value="COURSE" /><el-option label="SOP" value="SOP" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="16"><el-form-item label="已解析材料" required><el-select v-model="learningForm.materialAttachmentId" filterable class="full" placeholder="选择知识空间内已解析材料"><el-option v-for="item in materials" :key="item.attachmentId" :label="`${item.title} · ${item.materialVersion}`" :value="item.attachmentId" /></el-select></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="14">
|
||||
<el-col :span="8"><el-form-item label="完成条件" required><el-select v-model="learningForm.completionRule" class="full" @change="onCompletionRuleChange"><el-option label="员工确认完成" value="MANUAL_CONFIRM" /><el-option label="关联考试通过" value="EXAM_PASS" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="任务周期" required><el-select v-model="learningForm.recurrenceType" class="full" :disabled="learningForm.completionRule === 'EXAM_PASS'"><el-option label="单次" value="ONCE" /><el-option label="每日" value="DAILY" /><el-option label="每周" value="WEEKLY" /><el-option label="每月" value="MONTHLY" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="关联考试" :required="learningForm.completionRule === 'EXAM_PASS'"><el-select v-model="learningForm.examId" clearable filterable class="full" :disabled="learningForm.completionRule !== 'EXAM_PASS'" placeholder="选择已发布考试"><el-option v-for="item in eligibleExams" :key="item.examId" :label="`${item.title} · 截止 ${item.dueDate || '不限'}`" :value="item.examId" /></el-select></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="14"><el-col :span="12"><el-form-item label="生效时间" required><el-date-picker v-model="learningForm.startTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" class="full" /></el-form-item></el-col><el-col :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="learningForm.dueTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" class="full" /></el-form-item></el-col></el-row>
|
||||
<el-alert v-if="learningForm.completionRule === 'EXAM_PASS'" title="考试通过型任务仅支持单次派发,且所选员工必须已在该考试的发布名单中。" type="warning" :closable="false" />
|
||||
<people-picker :people-loading="peopleLoading" :eligible-people="eligiblePeople" :selected-count="selectedPeople.length" @selection-change="onSelectPeople" />
|
||||
</el-form>
|
||||
<template #footer><el-button @click="learningAssignVisible = false">取消</el-button><el-button type="danger" :loading="assigning" @click="assignLearning">确认派发</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue';
|
||||
import { ElAlert, ElMessage, ElMessageBox, ElTable, ElTableColumn } from 'element-plus';
|
||||
import {
|
||||
cancelManagedPracticeAssignment,
|
||||
createManagedPracticeAssignments,
|
||||
listManagedPracticeAssignments,
|
||||
listPracticeScenarios,
|
||||
type ManagedPracticeAssignment,
|
||||
type PracticeScenarioResponse
|
||||
cancelManagedPracticeAssignment, createManagedLearningTasks, createManagedPracticeAssignments,
|
||||
listLearningExams, listLearningMaterials, listManagedLearningTasks, listManagedPracticeAssignments, listPracticeScenarios,
|
||||
type LearningExamOption, type LearningMaterialOption, type ManagedLearningTask, type ManagedPracticeAssignment, type PracticeScenarioResponse
|
||||
} from '@/api/aihr/practice';
|
||||
import { getOrgSnapshot, type OrgPersonRow, type OrgProjectOption } from '@/api/aihr/mobile-admin';
|
||||
|
||||
const PeoplePicker = defineComponent({
|
||||
props: { peopleLoading: Boolean, eligiblePeople: { type: Array as () => OrgPersonRow[], required: true }, selectedCount: { type: Number, required: true } },
|
||||
emits: ['selection-change'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('div', { class: 'people-field' }, [
|
||||
h(ElAlert, { title: `当前岗位可派 ${props.eligiblePeople.length} 人,已选择 ${props.selectedCount} 人。服务端会复核在职、项目和岗位。`, type: 'info', closable: false }),
|
||||
h(ElTable, { data: props.eligiblePeople, loading: props.peopleLoading, maxHeight: 330, rowKey: 'extPartyId', onSelectionChange: (rows: OrgPersonRow[]) => emit('selection-change', rows) }, {
|
||||
default: () => [
|
||||
h(ElTableColumn, { type: 'selection', width: 48 }), h(ElTableColumn, { prop: 'personName', label: '员工', minWidth: 120 }),
|
||||
h(ElTableColumn, { prop: 'positionName', label: '组织岗位', minWidth: 160 }), h(ElTableColumn, { prop: 'projectName', label: '项目', minWidth: 150 }),
|
||||
h(ElTableColumn, { prop: 'extPartyId', label: '员工ID', minWidth: 140 })
|
||||
]
|
||||
})
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
|
||||
const loading = ref(false); const assigning = ref(false); const peopleLoading = ref(false); const assignVisible = ref(false);
|
||||
const rows = ref<ManagedPracticeAssignment[]>([]); const scenarios = ref<PracticeScenarioResponse[]>([]); const people = ref<OrgPersonRow[]>([]); const selectedPeople = ref<OrgPersonRow[]>([]); const projectOptions = ref<OrgProjectOption[]>([]);
|
||||
const filters = reactive({ position: '', status: '', source: '', keyword: '' });
|
||||
const projectCode = ref(''); const assignmentPosition = ref('生活顾问'); const assignmentScenarioId = ref(''); const dueDate = ref(''); const reason = ref('训练运营派发专项训练');
|
||||
const assignmentScenarios = computed(() => scenarios.value.filter((item) => item.enabled && item.position === assignmentPosition.value));
|
||||
const canonicalPosition = (value: string) => {
|
||||
const text = String(value || '').trim();
|
||||
if (text.includes('生活顾问') || text.includes('客服管家') || text.includes('物业管家') || text === '管家') return '生活顾问';
|
||||
if (text.includes('保安') || text.includes('秩序') || text.includes('安保')) return '保安';
|
||||
if (text.includes('保洁') || text.includes('环境')) return '保洁';
|
||||
if (text.includes('保修') || text.includes('维修') || text.includes('工程')) return '保修';
|
||||
if (text.includes('客服') || text.includes('前台')) return '客服';
|
||||
return text;
|
||||
};
|
||||
const activeView = ref<'practice' | 'learning'>('practice');
|
||||
const loading = ref(false); const assigning = ref(false); const peopleLoading = ref(false);
|
||||
const practiceAssignVisible = ref(false); const learningAssignVisible = ref(false);
|
||||
const practiceRows = ref<ManagedPracticeAssignment[]>([]); const learningRows = ref<ManagedLearningTask[]>([]);
|
||||
const scenarios = ref<PracticeScenarioResponse[]>([]); const people = ref<OrgPersonRow[]>([]); const selectedPeople = ref<OrgPersonRow[]>([]);
|
||||
const projectOptions = ref<OrgProjectOption[]>([]); const materials = ref<LearningMaterialOption[]>([]); const exams = ref<LearningExamOption[]>([]);
|
||||
const practiceFilters = reactive({ position: '', status: '', source: '', keyword: '' });
|
||||
const learningFilters = reactive({ status: '', keyword: '' });
|
||||
const projectCode = ref(''); const assignmentPosition = ref('生活顾问'); const assignmentScenarioId = ref('');
|
||||
const practiceDueDate = ref(''); const reason = ref('训练运营派发专项训练');
|
||||
const learningForm = reactive({ taskType: '岗位必修学习', materialType: 'DOCUMENT' as ManagedLearningTask['materialType'], materialAttachmentId: null as number | null, completionRule: 'MANUAL_CONFIRM' as ManagedLearningTask['completionRule'], examId: null as number | null, recurrenceType: 'ONCE' as ManagedLearningTask['recurrenceType'], startTime: '', dueTime: '' });
|
||||
|
||||
const canonicalPosition = (value: string) => { const text = String(value || '').trim(); if (text.includes('生活顾问') || text.includes('客服管家') || text.includes('物业管家') || text === '管家') return '生活顾问'; if (text.includes('保安') || text.includes('秩序') || text.includes('安保')) return '保安'; if (text.includes('保洁') || text.includes('环境')) return '保洁'; if (text.includes('保修') || text.includes('维修') || text.includes('工程')) return '保修'; if (text.includes('客服') || text.includes('前台')) return '客服'; return text; };
|
||||
const eligiblePeople = computed(() => people.value.filter((item) => canonicalPosition(item.positionName) === assignmentPosition.value));
|
||||
const pendingCount = computed(() => rows.value.filter((item) => item.status === '待训练').length); const completedCount = computed(() => rows.value.filter((item) => item.status === '已完成').length); const withdrawnCount = computed(() => rows.value.filter((item) => item.status === '已撤回').length);
|
||||
const assignmentScenarios = computed(() => scenarios.value.filter((item) => item.enabled && item.position === assignmentPosition.value));
|
||||
const eligibleExams = computed(() => exams.value.filter((item) => (!item.projectCode || item.projectCode === projectCode.value) && (!item.positionCode || canonicalPosition(item.positionCode) === assignmentPosition.value)));
|
||||
const sourceLabel = (source: string) => ({ daily: '每日三题', manual: '人工专项', retry: '低分重练', camp: '训练营' }[source] || source);
|
||||
const statusType = (status: string) => status === '待训练' ? 'warning' : status === '已完成' ? 'success' : 'info';
|
||||
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const statusType = (status: string) => status === '已完成' ? 'success' : status === '待训练' || status === '待完成' ? 'warning' : 'info';
|
||||
const completionLabel = (value: string) => value === 'EXAM_PASS' ? '考试通过' : '员工确认';
|
||||
const recurrenceLabel = (value: string) => ({ ONCE: '单次', DAILY: '每日', WEEKLY: '每周', MONTHLY: '每月' }[value] || value);
|
||||
const materialTypeLabel = (value: string) => ({ DOCUMENT: '文档', VIDEO: '视频', COURSE: '课程', SOP: 'SOP' }[value] || value);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
const formatDate = (date: Date, withTime = false) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}${withTime ? ` ${pad(date.getHours())}:${pad(date.getMinutes())}:00` : ''}`;
|
||||
const disablePast = (date: Date) => date.getTime() < new Date(new Date().setHours(0, 0, 0, 0)).getTime();
|
||||
const load = async () => { loading.value = true; try { const result = await listManagedPracticeAssignments({ ...filters, limit: 200 }); rows.value = result.data || []; } catch { ElMessage.error('训练任务读取失败'); } finally { loading.value = false; } };
|
||||
const loadScenarios = async () => { const result = await listPracticeScenarios({ includeDisabled: false }); scenarios.value = result.data || []; onAssignmentPositionChange(); };
|
||||
|
||||
const loadPractice = async () => { loading.value = true; try { practiceRows.value = (await listManagedPracticeAssignments({ ...practiceFilters, limit: 200 })).data || []; } catch { ElMessage.error('训练任务读取失败'); } finally { loading.value = false; } };
|
||||
const loadLearning = async () => { loading.value = true; try { learningRows.value = (await listManagedLearningTasks({ ...learningFilters, limit: 200 })).data || []; } catch { ElMessage.error('学习任务读取失败'); } finally { loading.value = false; } };
|
||||
const loadCurrentView = () => activeView.value === 'practice' ? loadPractice() : loadLearning();
|
||||
const loadScenarios = async () => { scenarios.value = (await listPracticeScenarios({ includeDisabled: false })).data || []; onAssignmentPositionChange(); };
|
||||
const loadOptions = async () => { const [materialResult, examResult] = await Promise.all([listLearningMaterials(), listLearningExams()]); materials.value = materialResult.data || []; exams.value = examResult.data || []; };
|
||||
const loadPeople = async () => { peopleLoading.value = true; try { const result = await getOrgSnapshot({ status: 'active', projectCode: projectCode.value || undefined, pageNum: 1, pageSize: 500 }); people.value = result.data?.rows || []; projectOptions.value = result.data?.projects || projectOptions.value; selectedPeople.value = []; } catch { people.value = []; ElMessage.error('组织名单读取失败'); } finally { peopleLoading.value = false; } };
|
||||
const onAssignmentPositionChange = () => { selectedPeople.value = []; if (!assignmentScenarios.value.some((item) => item.id === assignmentScenarioId.value)) assignmentScenarioId.value = assignmentScenarios.value[0]?.id || ''; };
|
||||
const onSelectPeople = (value: OrgPersonRow[]) => { selectedPeople.value = value.slice(0, 100); if (value.length > 100) ElMessage.warning('单次最多派发 100 人'); };
|
||||
const openAssign = async () => { assignVisible.value = true; dueDate.value = formatDate(new Date(Date.now() + 86400000)); await Promise.all([loadScenarios(), loadPeople()]); };
|
||||
const assign = async () => { if (!assignmentScenarioId.value || !dueDate.value || !selectedPeople.value.length) { ElMessage.warning('请先选择场景、截止日期和员工'); return; } assigning.value = true; try { const result = await createManagedPracticeAssignments({ requestId: `hr-task:${Date.now()}`, targets: selectedPeople.value.map((item) => ({ extPartyId: item.extPartyId, traineeName: item.personName })), scenarioId: assignmentScenarioId.value, reason: reason.value.trim() || '训练运营派发专项训练', dueDate: dueDate.value }); const failed = result.data?.failed || 0; ElMessage.success(failed ? `已派发,${failed} 人未成功` : '批量派发成功'); assignVisible.value = false; await load(); } catch { ElMessage.error('专项训练派发失败'); } finally { assigning.value = false; } };
|
||||
const withdraw = async (row: ManagedPracticeAssignment) => { await ElMessageBox.confirm(`撤回“${row.scenarioName}”任务?员工尚未开始训练时才可撤回。`, '确认撤回', { type: 'warning' }); await cancelManagedPracticeAssignment(row.id, '训练运营调整'); ElMessage.success('任务已撤回'); await load(); };
|
||||
onMounted(async () => { await Promise.all([load(), loadScenarios(), loadPeople()]); });
|
||||
const onLearningScopeChange = async () => { selectedPeople.value = []; learningForm.examId = null; await loadPeople(); };
|
||||
const onCompletionRuleChange = () => { if (learningForm.completionRule === 'MANUAL_CONFIRM') learningForm.examId = null; else learningForm.recurrenceType = 'ONCE'; };
|
||||
const onSelectPeople = (value: OrgPersonRow[]) => { selectedPeople.value = value.slice(0, 100); if (value.length > 100) ElMessage.warning('单次最多派发100人'); };
|
||||
|
||||
const openPracticeAssign = async () => { practiceAssignVisible.value = true; practiceDueDate.value = formatDate(new Date(Date.now() + 86400000)); await Promise.all([loadScenarios(), loadPeople()]); };
|
||||
const openLearningAssign = async () => { const now = new Date(); now.setSeconds(0, 0); learningForm.startTime = formatDate(now, true); learningForm.dueTime = formatDate(new Date(now.getTime() + 86400000), true); learningAssignVisible.value = true; await Promise.all([loadOptions(), loadPeople()]); };
|
||||
const assignPractice = async () => { if (!assignmentScenarioId.value || !practiceDueDate.value || !selectedPeople.value.length) { ElMessage.warning('请先选择场景、截止日期和员工'); return; } assigning.value = true; try { const result = await createManagedPracticeAssignments({ requestId: `hr-task:${Date.now()}`, targets: selectedPeople.value.map((item) => ({ extPartyId: item.extPartyId, traineeName: item.personName })), scenarioId: assignmentScenarioId.value, reason: reason.value.trim() || '训练运营派发专项训练', dueDate: practiceDueDate.value }); const failed = result.data?.failed || 0; ElMessage.success(failed ? `已派发,${failed} 人未成功` : '批量派发成功'); practiceAssignVisible.value = false; await loadPractice(); } finally { assigning.value = false; } };
|
||||
const assignLearning = async () => { if (!projectCode.value || !learningForm.taskType.trim() || !learningForm.materialAttachmentId || !learningForm.startTime || !learningForm.dueTime || !selectedPeople.value.length) { ElMessage.warning('请补齐项目、任务、材料、时间和员工'); return; } if (learningForm.completionRule === 'EXAM_PASS' && !learningForm.examId) { ElMessage.warning('请选择已发布考试'); return; } assigning.value = true; try { const result = await createManagedLearningTasks({ requestId: `learning-task:${Date.now()}`, targets: selectedPeople.value.map((item) => ({ extPartyId: item.extPartyId, traineeName: item.personName })), projectCode: projectCode.value, positionCode: assignmentPosition.value, taskType: learningForm.taskType.trim(), materialType: learningForm.materialType, materialAttachmentId: learningForm.materialAttachmentId, completionRule: learningForm.completionRule, examId: learningForm.examId, recurrenceType: learningForm.recurrenceType, startTime: learningForm.startTime, dueTime: learningForm.dueTime }); const data = result.data; if (data.failed) { const failures = data.items.filter((item) => item.status === 'FAILED').map((item) => `${item.traineeName || item.extPartyId}: ${item.message}`).join('\n'); await ElMessageBox.alert(`成功 ${data.created} 人,重复 ${data.duplicate} 人,失败 ${data.failed} 人。\n${failures}`, '派发结果', { type: 'warning' }); } else ElMessage.success(`学习任务已派发给 ${data.created} 人${data.duplicate ? `,重复 ${data.duplicate} 人` : ''}`); learningAssignVisible.value = false; await loadLearning(); } finally { assigning.value = false; } };
|
||||
const withdraw = async (row: ManagedPracticeAssignment) => { await ElMessageBox.confirm(`撤回“${row.scenarioName}”任务?员工尚未开始训练时才可撤回。`, '确认撤回', { type: 'warning' }); await cancelManagedPracticeAssignment(row.id, '训练运营调整'); ElMessage.success('任务已撤回'); await loadPractice(); };
|
||||
|
||||
onMounted(async () => { await Promise.all([loadPractice(), loadScenarios(), loadPeople()]); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tasks-page { min-height: calc(100vh - 84px); padding: 24px 28px 40px; background: #f4f6fa; color: #172033; }
|
||||
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }.page-head h1 { margin: 0; font-size: 24px; }.page-head p { margin: 8px 0 0; color: #667085; font-size: 13px; }
|
||||
.filter-card, .table-card { padding: 18px; border-radius: 10px; background: #fff; }.filter-card { display: grid; grid-template-columns: 160px 140px 140px minmax(180px, 1fr) 76px; gap: 12px; margin-bottom: 12px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 12px; }.metric-grid > div { display: grid; gap: 8px; padding: 16px; border-radius: 10px; background: #fff; }.metric-grid span, .muted { color: #98a2b3; }.metric-grid strong { font-size: 26px; }.full { width: 100%; }
|
||||
@media (max-width: 1000px) { .filter-card { grid-template-columns: 1fr 1fr; }.metric-grid { grid-template-columns: 1fr 1fr; } }
|
||||
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }.page-head h1 { margin: 0; font-size: 24px; }.page-head p { margin: 8px 0 0; color: #667085; font-size: 13px; }.view-switch { margin-bottom: 14px; }
|
||||
.filter-band, .table-band { padding: 18px; background: #fff; }.filter-band { display: grid; gap: 12px; margin-bottom: 12px; }.practice-filter { grid-template-columns: 160px 140px 140px minmax(180px, 1fr) 76px; }.learning-filter { grid-template-columns: 160px minmax(240px, 1fr) 76px; }.table-band { overflow: hidden; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 12px; }.metric-grid > div { display: grid; gap: 8px; padding: 16px; background: #fff; }.metric-grid span, .muted, small { color: #98a2b3; }.metric-grid strong { font-size: 26px; }.full { width: 100%; }.people-field { display: grid; gap: 10px; margin-top: 8px; }
|
||||
@media (max-width: 1000px) { .practice-filter, .learning-filter { grid-template-columns: 1fr 1fr; }.metric-grid { grid-template-columns: 1fr 1fr; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user