feat(aihr): 重构运营后台与租户知识治理

- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
2026-07-23 01:24:22 +08:00
parent 333c365c6f
commit 163b08ecfe
94 changed files with 8610 additions and 1114 deletions
@@ -0,0 +1,301 @@
<template>
<div class="work-report-page">
<section class="page-head">
<div>
<h1>成果投稿审核</h1>
<p>审核员工提交的案例、视频、SOP 和知识素材;通过后仍需在对应业务库完成最终入库。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="loadReports">刷新</el-button>
</section>
<el-alert v-if="loadError" class="data-alert" type="warning" :closable="false" show-icon title="待审核投稿暂不可读取,页面未展示本地演示数据。" />
<section class="table-card">
<div class="section-title">
<div>
<h2>待审核投稿</h2>
<p>当前仅加载状态为 PENDING 的投稿。</p>
</div>
<el-tag type="warning">{{ reports.length }} 条</el-tag>
</div>
<el-table v-loading="loading" :data="reports" size="small" empty-text="暂无待审核投稿">
<el-table-column prop="typeLabel" label="类型" width="120">
<template #default="{ row }">{{ row.typeLabel || row.type }}</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="220" show-overflow-tooltip />
<el-table-column prop="submitterName" label="投稿人" width="120" />
<el-table-column prop="attachmentName" label="附件" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.attachmentName || '无附件' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="openReview(row)">审核</el-button>
</template>
</el-table-column>
</el-table>
</section>
<el-drawer v-model="drawerVisible" :title="selectedReport?.title || '成果投稿审核'" size="560px">
<template v-if="selectedReport">
<div class="report-meta">
<el-tag>{{ selectedReport.typeLabel || selectedReport.type }}</el-tag>
<span>{{ selectedReport.submitterName }} · {{ selectedReport.createdAt }}</span>
</div>
<h2>{{ selectedReport.title }}</h2>
<p class="report-content">{{ selectedReport.content }}</p>
<div v-if="selectedReport.attachmentName" class="attachment-name">
<span>附件:{{ selectedReport.attachmentName }}</span>
<el-button
v-if="selectedReport.attachmentAvailable && selectedReport.attachmentUrl"
link
type="primary"
:loading="attachmentLoading"
@click="viewAttachment"
>
受控查看
</el-button>
<span v-else class="attachment-unavailable">附件暂不可读取</span>
</div>
<el-alert
v-if="requiresAttachmentReview && !attachmentViewed"
class="attachment-alert"
type="warning"
:closable="false"
show-icon
title="请先通过受控查看读取投稿附件,再审核通过。"
/>
<el-form label-position="top" class="review-form">
<el-form-item label="审核意见">
<el-input v-model="reviewNote" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="通过可选填;驳回请说明原因" />
</el-form-item>
</el-form>
<div class="review-actions">
<el-button type="danger" plain :loading="reviewing" @click="submitReview('REJECT')">驳回</el-button>
<el-button type="danger" :loading="reviewing" :disabled="requiresAttachmentReview && !attachmentViewed" @click="submitReview('APPROVE')">
审核通过
</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup name="OperationsWorkReportReview" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import { getWorkReportAttachment, listWorkReports, reviewWorkReport, type WorkReport } from '@/api/aihr/work-report';
const reports = ref<WorkReport[]>([]);
const selectedReport = ref<WorkReport>();
const loading = ref(false);
const reviewing = ref(false);
const loadError = ref(false);
const drawerVisible = ref(false);
const reviewNote = ref('');
const attachmentLoading = ref(false);
const attachmentViewed = ref(false);
const requiresAttachmentReview = computed(() => Boolean(selectedReport.value?.attachmentName) || selectedReport.value?.type === 'VIDEO');
const loadReports = async () => {
loading.value = true;
loadError.value = false;
try {
const response = await listWorkReports('PENDING');
reports.value = response.data ?? [];
} catch {
reports.value = [];
loadError.value = true;
} finally {
loading.value = false;
}
};
const openReview = (report: WorkReport) => {
selectedReport.value = report;
reviewNote.value = '';
attachmentViewed.value = false;
drawerVisible.value = true;
};
const viewAttachment = async () => {
const report = selectedReport.value;
if (!report?.attachmentAvailable || !report.attachmentUrl) {
ElMessage.warning('该附件暂不可读取');
return;
}
attachmentLoading.value = true;
try {
const blob = await getWorkReportAttachment(report.attachmentUrl);
if (!blob.size) throw new Error('empty attachment');
attachmentViewed.value = true;
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
} catch {
ElMessage.error('附件读取失败,请稍后重试');
} finally {
attachmentLoading.value = false;
}
};
const submitReview = async (decision: 'APPROVE' | 'REJECT') => {
if (!selectedReport.value) return;
const note = reviewNote.value.trim();
if (decision === 'REJECT' && !note) {
ElMessage.warning('驳回时请填写审核意见');
return;
}
reviewing.value = true;
try {
await reviewWorkReport(selectedReport.value.id, { decision, note: note || undefined });
drawerVisible.value = false;
ElMessage.success(decision === 'APPROVE' ? '投稿已审核通过' : '投稿已驳回');
await loadReports();
} catch {
ElMessage.error('审核提交失败,请重试');
} finally {
reviewing.value = false;
}
};
onMounted(loadReports);
</script>
<style scoped lang="scss">
.work-report-page {
min-height: calc(100vh - 84px);
padding: 24px 28px 40px;
background: #f4f6fa;
color: #172033;
}
.page-head,
.section-title,
.report-meta,
.review-actions {
display: flex;
align-items: center;
}
.page-head,
.section-title {
justify-content: space-between;
}
.page-head {
align-items: flex-start;
margin-bottom: 18px;
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 24px;
}
p {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
}
.section-title p {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
.data-alert {
margin-bottom: 12px;
}
.table-card {
padding: 18px;
border-radius: 8px;
background: #fff;
.section-title {
margin-bottom: 16px;
}
h2 {
font-size: 18px;
}
:deep(.el-table th.el-table__cell) {
color: #667085;
font-weight: 500;
background: #fafbfc;
}
}
.report-meta,
.review-actions {
gap: 10px;
}
.report-meta {
margin-bottom: 20px;
color: #667085;
font-size: 13px;
}
.report-content {
min-height: 120px;
color: #344054;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.attachment-name {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
background: #f7f8fa;
color: #667085;
}
.attachment-unavailable {
color: #c0781a;
font-size: 13px;
}
.attachment-alert {
margin-top: 14px;
}
.review-form {
margin-top: 24px;
}
.review-actions {
justify-content: flex-end;
margin-top: 22px;
}
@media (max-width: 760px) {
.work-report-page {
padding: 16px;
}
.page-head {
gap: 12px;
flex-direction: column;
}
}
</style>
@@ -0,0 +1,422 @@
<template>
<div class="work-results-page">
<section class="page-head">
<div>
<h1>项目工作成果</h1>
<p>按项目和自然日汇总员工已确认的工作助手记录,主管范围由服务端按当前身份复核。</p>
</div>
<el-button :icon="Refresh" :loading="projectsLoading || resultLoading" @click="refresh">刷新</el-button>
</section>
<el-alert
v-if="projectsError"
class="data-alert"
type="warning"
:closable="false"
show-icon
title="项目列表暂未返回,无法查询团队工作成果。请完成项目权限配置后刷新。"
/>
<el-alert
v-else-if="resultError"
class="data-alert"
type="warning"
:closable="false"
show-icon
title="所选项目的工作成果暂不可读取,请刷新后重试。"
/>
<section class="filter-card">
<el-select
v-model="projectCode"
class="project-select"
filterable
placeholder="请选择项目"
:loading="projectsLoading"
:disabled="!projects.length"
@change="resetAndLoad"
>
<el-option v-for="project in projects" :key="project.projectCode" :label="project.projectName" :value="project.projectCode" />
</el-select>
<el-date-picker v-model="workDate" type="date" value-format="YYYY-MM-DD" placeholder="工作日期" :disabled-date="disableFutureDate" />
<el-button type="danger" :loading="resultLoading" :disabled="!projectCode" @click="resetAndLoad">查询</el-button>
</section>
<el-empty v-if="!projectsLoading && !projects.length" class="empty-state" description="暂无可用项目;页面不会使用默认或猜测的项目编码。" />
<template v-else-if="projectResult">
<section class="metric-grid">
<div class="metric-card">
<span>贡献员工</span>
<strong>{{ projectResult.contributorCount }}</strong>
</div>
<div class="metric-card">
<span>已汇总成果</span>
<strong>{{ projectResult.resultCount }}</strong>
</div>
<div class="metric-card warning">
<span>待跟进</span>
<strong>{{ projectResult.pendingCount }}</strong>
</div>
<div class="metric-card danger">
<span>高优先级</span>
<strong>{{ projectResult.highPriorityCount }}</strong>
</div>
</section>
<section class="table-card">
<div class="section-title">
<div>
<h2>{{ projectResult.projectName }}</h2>
<p>
{{ projectResult.workDate }} · 第 {{ projectResult.pageNum }} 页展示 {{ projectResult.members.length }} /
{{ projectResult.contributorCount }} 位员工的已确认记录
</p>
</div>
<el-tag type="info">{{ projectResult.projectCode }}</el-tag>
</div>
<el-table :data="projectResult.members" size="small" empty-text="该日期暂无已确认工作成果">
<el-table-column type="expand" width="48">
<template #default="{ row }">
<div class="member-details">
<div v-for="section in resultSections(row.sections)" :key="section.key" class="result-section">
<strong>{{ sectionLabel(section.key) }}({{ section.items.length }})</strong>
<div v-if="section.items.length" class="result-list">
<div v-for="item in section.items" :key="item.captureId" class="result-item">
<span>{{ item.summary }}</span>
<small>{{ item.occurredAt }} · {{ item.category || item.suggestedType }}</small>
</div>
</div>
<span v-else class="muted">暂无记录</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="ownerName" label="员工" min-width="130" />
<el-table-column prop="total" label="成果数" width="100" />
<el-table-column label="待跟进" width="110">
<template #default="{ row }">
<el-tag :type="row.pending ? 'warning' : 'success'" size="small">{{ row.pending }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="88" />
<el-table-column prop="generatedAt" label="汇总时间" min-width="180" />
</el-table>
<el-pagination
v-if="projectResult.contributorCount > projectResult.pageSize"
class="member-pagination"
background
layout="prev, pager, next"
:current-page="projectResult.pageNum"
:page-size="projectResult.pageSize"
:total="projectResult.contributorCount"
@current-change="changePage"
/>
</section>
</template>
<el-empty v-else-if="!projectsLoading && projectCode && !resultLoading" class="empty-state" description="请选择日期后查询项目工作成果。" />
</div>
</template>
<script setup name="OperationsWorkResults" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { onMounted, ref, watch } from 'vue';
import {
getProjectWorkResult,
listWorkResultProjects,
type ProjectWorkResult,
type WorkResultItem,
type WorkResultProject
} from '@/api/aihr/work-results';
const localDate = () => {
const date = new Date();
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
const projects = ref<WorkResultProject[]>([]);
const projectCode = ref('');
const workDate = ref(localDate());
const projectResult = ref<ProjectWorkResult>();
const pageNum = ref(1);
const pageSize = 20;
const projectsLoading = ref(false);
const resultLoading = ref(false);
const projectsError = ref(false);
const resultError = ref(false);
const disableFutureDate = (date: Date) => date.getTime() > Date.now();
const sectionLabels: Record<string, string> = {
completed: '已完成',
inspection: '巡检',
services: '服务记录',
leads: '服务线索',
attendance: '考勤',
pending: '待跟进'
};
const sectionLabel = (section: string) => sectionLabels[section] || section;
const resultSections = (sections: Record<string, WorkResultItem[]>) => Object.entries(sections).map(([key, items]) => ({ key, items }));
const loadProjects = async () => {
projectsLoading.value = true;
projectsError.value = false;
try {
const response = await listWorkResultProjects();
projects.value = response.data ?? [];
if (!projects.value.some((project) => project.projectCode === projectCode.value)) {
projectCode.value = projects.value[0]?.projectCode || '';
}
} catch {
projects.value = [];
projectCode.value = '';
projectResult.value = undefined;
projectsError.value = true;
} finally {
projectsLoading.value = false;
}
};
const loadProjectResult = async () => {
if (!projectCode.value) return;
resultLoading.value = true;
resultError.value = false;
try {
const response = await getProjectWorkResult({
projectCode: projectCode.value,
workDate: workDate.value,
pageNum: pageNum.value,
pageSize
});
projectResult.value = response.data;
} catch {
projectResult.value = undefined;
resultError.value = true;
} finally {
resultLoading.value = false;
}
};
const resetAndLoad = () => {
pageNum.value = 1;
return loadProjectResult();
};
const changePage = (page: number) => {
pageNum.value = page;
return loadProjectResult();
};
const refresh = async () => {
await loadProjects();
if (projectCode.value) await loadProjectResult();
};
watch(workDate, () => {
projectResult.value = undefined;
pageNum.value = 1;
});
onMounted(async () => {
await loadProjects();
if (projectCode.value) await loadProjectResult();
});
</script>
<style scoped lang="scss">
.work-results-page {
min-height: calc(100vh - 84px);
padding: 24px 28px 40px;
background: #f4f6fa;
color: #172033;
}
.page-head,
.filter-card,
.section-title {
display: flex;
align-items: center;
}
.page-head,
.section-title {
justify-content: space-between;
}
.page-head {
align-items: flex-start;
margin-bottom: 18px;
h1,
p,
h2 {
margin: 0;
}
h1 {
font-size: 24px;
}
p {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
}
.section-title p,
.muted {
margin-top: 6px;
color: #858991;
font-size: 13px;
}
.data-alert {
margin-bottom: 12px;
}
.filter-card,
.table-card {
padding: 18px;
border-radius: 8px;
background: #fff;
}
.filter-card {
gap: 12px;
margin-bottom: 16px;
}
.project-select {
min-width: 280px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.member-pagination {
justify-content: flex-end;
margin-top: 16px;
}
.metric-card {
padding: 16px 18px;
border-radius: 8px;
background: #fff;
span,
strong {
display: block;
}
span {
color: #858991;
font-size: 13px;
}
strong {
margin-top: 8px;
color: #253044;
font-size: 26px;
}
&.warning strong {
color: #d97706;
}
&.danger strong {
color: #d92d20;
}
}
.table-card {
.section-title {
margin-bottom: 16px;
}
h2 {
font-size: 18px;
}
:deep(.el-table th.el-table__cell) {
color: #667085;
font-weight: 500;
background: #fafbfc;
}
}
.member-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 4px 16px 12px;
}
.result-section {
min-width: 0;
padding: 12px;
border-radius: 6px;
background: #fafbfc;
}
.result-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.result-item {
span,
small {
display: block;
}
span {
overflow-wrap: anywhere;
line-height: 1.5;
}
small {
margin-top: 3px;
color: #858991;
}
}
.empty-state {
padding: 44px 0;
border-radius: 8px;
background: #fff;
}
@media (max-width: 960px) {
.work-results-page {
padding: 16px;
}
.filter-card,
.page-head {
align-items: stretch;
flex-direction: column;
}
.project-select {
min-width: 0;
}
.metric-grid,
.member-details {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.metric-grid,
.member-details {
grid-template-columns: 1fr;
}
}
</style>