feat(aihr): 重构运营后台与租户知识治理
- 重构运营总览、侧栏和内容运营工作台\n- 增加大喇叭、成果审核与运营统计链路\n- 补齐租户、知识空间、分类和知识维护闭环\n- 加固标签页租户上下文、停用租户写保护与迁移预检
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
<template>
|
||||
<div class="broadcast-page">
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>银城大喇叭</h1>
|
||||
<p>向当前租户全员公开发布公司通知。发布后立即可见;撤回会停止员工端展示,但保留发布记录。</p>
|
||||
</div>
|
||||
<el-button type="danger" icon="Plus" :disabled="publishing || publishPending || !scopeTenantId || !storageAvailable" @click="openPublish">发布消息</el-button>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="loadError"
|
||||
class="data-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="消息管理暂不可读取。发布与管理仅限 superadmin 或 hr_operator,请确认当前账号权限后重试。"
|
||||
/>
|
||||
|
||||
<section class="table-card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2>消息管理</h2>
|
||||
<p>当前支持立即发布、查看版本号和撤回;发布重试不会重复推送,撤回会保留初次审计原因。</p>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<el-select v-model="status" class="status-select" placeholder="全部状态" :disabled="publishing" @change="handleStatusChange">
|
||||
<el-option label="全部状态" value="" />
|
||||
<el-option label="已发布" value="PUBLISHED" />
|
||||
<el-option label="已撤回" value="WITHDRAWN" />
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" :loading="loading" :disabled="publishing" @click="() => void loadMessages()">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="messages" size="small" empty-text="暂无公司消息">
|
||||
<el-table-column prop="title" label="标题" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="内容摘要" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.content }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="64" align="center">
|
||||
<template #default="{ row }">v{{ row.currentVersion }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="76" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'PUBLISHED' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'PUBLISHED' ? '已发布' : '已撤回' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布信息" width="140">
|
||||
<template #default="{ row }">
|
||||
<div class="audit-info">
|
||||
<span>管理员 #{{ row.publishedBy }}</span>
|
||||
<small class="audit-time">{{ displayTime(row.publishedAt) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="撤回信息" width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.withdrawnAt" class="withdraw-info">
|
||||
<span>管理员 #{{ row.withdrawnBy }}</span>
|
||||
<small class="audit-time">{{ displayTime(row.withdrawnAt) }}</small>
|
||||
<small v-if="row.withdrawReason">原因:{{ row.withdrawReason }}</small>
|
||||
</div>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="76" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'PUBLISHED'" link type="danger" :loading="withdrawingId === row.id" @click="handleWithdraw(row)">
|
||||
撤回
|
||||
</el-button>
|
||||
<span v-else class="muted">已撤回</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:page="pageNum" v-model:limit="pageSize" :total="total" @pagination="() => void loadMessages()" />
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="publishVisible"
|
||||
title="发布银城大喇叭消息"
|
||||
width="680px"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
:show-close="!publishing"
|
||||
:before-close="beforeClosePublish"
|
||||
>
|
||||
<el-alert
|
||||
class="publish-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="消息会立即向当前租户员工端公开,请确认内容不含敏感人员、薪酬或审批材料。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="publishPending"
|
||||
class="publish-alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="上一次发布结果尚未确认。系统已保留原请求和内容;请重试发布,或明确放弃后再新建消息。"
|
||||
/>
|
||||
<el-form ref="publishFormRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
:disabled="publishing || publishPending"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="例如:夏季服务标准通知"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="正文" prop="content">
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
:disabled="publishing || publishPending"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
maxlength="10000"
|
||||
show-word-limit
|
||||
placeholder="请输入完整通知正文"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="publishing" @click="requestClosePublish">{{ publishPending ? '放弃并关闭' : '取消' }}</el-button>
|
||||
<el-button type="danger" :loading="publishing" @click="submitPublish">{{ publishPending ? '重试发布' : '确认发布' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="ContentBroadcast" lang="ts">
|
||||
import { Refresh } from '@element-plus/icons-vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
listBroadcastAdminMessages,
|
||||
publishBroadcast,
|
||||
withdrawBroadcast,
|
||||
type BroadcastAdminMessage,
|
||||
type BroadcastStatus
|
||||
} from '@/api/aihr/broadcast';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const messages = ref<BroadcastAdminMessage[]>([]);
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const status = ref<BroadcastStatus | ''>('');
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const publishVisible = ref(false);
|
||||
const publishing = ref(false);
|
||||
const publishPending = ref(false);
|
||||
const withdrawingId = ref<number>();
|
||||
const publishFormRef = ref<FormInstance>();
|
||||
const form = reactive({ requestId: '', title: '', content: '' });
|
||||
const userStore = useUserStore();
|
||||
const scopeTenantId = ref('');
|
||||
const draftTenantId = ref('');
|
||||
const pendingTenantId = ref('');
|
||||
const storageAvailable = ref(true);
|
||||
const rules = {
|
||||
title: [{ required: true, whitespace: true, message: '请输入消息标题', trigger: 'blur' }],
|
||||
content: [{ required: true, whitespace: true, message: '请输入消息正文', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
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)}`;
|
||||
const pendingPublishStoragePrefix = 'aihr.broadcast.pending.v2';
|
||||
type PendingPublish = { tenantId: string; requestId: string; title: string; content: string };
|
||||
|
||||
const isValidPendingPublish = (candidate: Partial<PendingPublish>): candidate is PendingPublish =>
|
||||
typeof candidate.tenantId === 'string' && candidate.tenantId === scopeTenantId.value &&
|
||||
candidate.tenantId === candidate.tenantId.trim() && candidate.tenantId.length <= 64 &&
|
||||
typeof candidate.requestId === 'string' && candidate.requestId === candidate.requestId.trim() &&
|
||||
/^[A-Za-z0-9._:-]{1,100}$/.test(candidate.requestId) &&
|
||||
typeof candidate.title === 'string' && !!candidate.title.trim() && candidate.title.length <= 200 &&
|
||||
typeof candidate.content === 'string' && !!candidate.content.trim() && candidate.content.length <= 10_000;
|
||||
|
||||
const pendingPublishStorageKey = (tenantId = pendingTenantId.value || scopeTenantId.value) =>
|
||||
tenantId ? `${pendingPublishStoragePrefix}:${tenantId}:${userStore.userId || 'unknown'}` : '';
|
||||
|
||||
const safelyReadStorage = (storageKey: string) => {
|
||||
try {
|
||||
return storageKey ? localStorage.getItem(storageKey) : null;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
ElMessage.warning('浏览器暂时无法读取待确认发布记录,请保持本页面打开后重试。');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safelyRemoveStorage = (storageKey: string) => {
|
||||
try {
|
||||
if (storageKey) localStorage.removeItem(storageKey);
|
||||
return true;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
ElMessage.warning('浏览器暂时无法清理待确认发布记录,请勿在其他窗口重复发布。');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const persistPendingPublish = () => {
|
||||
pendingTenantId.value = draftTenantId.value;
|
||||
const storageKey = pendingPublishStorageKey();
|
||||
const pending: PendingPublish = {
|
||||
tenantId: pendingTenantId.value,
|
||||
requestId: form.requestId,
|
||||
title: form.title,
|
||||
content: form.content
|
||||
};
|
||||
try {
|
||||
if (!storageKey) throw new Error('missing tenant scope');
|
||||
localStorage.setItem(storageKey, JSON.stringify(pending));
|
||||
return true;
|
||||
} catch {
|
||||
storageAvailable.value = false;
|
||||
pendingTenantId.value = '';
|
||||
ElMessage.error('无法保存待确认发布记录,请检查浏览器存储权限后重试。');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPendingPublish = () => {
|
||||
const cleared = safelyRemoveStorage(pendingPublishStorageKey());
|
||||
if (cleared) pendingTenantId.value = '';
|
||||
return cleared;
|
||||
};
|
||||
|
||||
const readPendingPublish = (): PendingPublish | undefined => {
|
||||
const storageKey = pendingPublishStorageKey(scopeTenantId.value);
|
||||
const stored = safelyReadStorage(storageKey);
|
||||
if (!stored) return undefined;
|
||||
try {
|
||||
const candidate = JSON.parse(stored) as Partial<PendingPublish>;
|
||||
if (!isValidPendingPublish(candidate)) {
|
||||
throw new Error('invalid pending publish');
|
||||
}
|
||||
return {
|
||||
tenantId: candidate.tenantId,
|
||||
requestId: candidate.requestId,
|
||||
title: candidate.title,
|
||||
content: candidate.content
|
||||
};
|
||||
} catch {
|
||||
safelyRemoveStorage(storageKey);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPendingPublish = (candidate: PendingPublish) => {
|
||||
draftTenantId.value = candidate.tenantId;
|
||||
pendingTenantId.value = candidate.tenantId;
|
||||
form.requestId = candidate.requestId;
|
||||
form.title = candidate.title;
|
||||
form.content = candidate.content;
|
||||
publishPending.value = true;
|
||||
publishVisible.value = true;
|
||||
};
|
||||
|
||||
const restorePendingPublish = () => {
|
||||
if (publishPending.value && pendingTenantId.value === scopeTenantId.value) return false;
|
||||
const candidate = readPendingPublish();
|
||||
if (!candidate) return false;
|
||||
applyPendingPublish(candidate);
|
||||
return true;
|
||||
};
|
||||
|
||||
const loadMessages = async (allowWhilePublishing = false) => {
|
||||
if (publishing.value && !allowWhilePublishing) return;
|
||||
loading.value = true;
|
||||
loadError.value = false;
|
||||
try {
|
||||
const response = await listBroadcastAdminMessages({
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
status: status.value || undefined
|
||||
});
|
||||
const resolvedTenantId = String(response.data?.tenantId ?? '').trim();
|
||||
if (!resolvedTenantId) throw new Error('missing broadcast tenant scope');
|
||||
if (scopeTenantId.value && scopeTenantId.value !== resolvedTenantId && publishVisible.value) {
|
||||
// A tenant switch invalidates both an unresolved request and an open draft. Keep an
|
||||
// unresolved request only in its original v2 storage key; never retarget its content.
|
||||
publishPending.value = false;
|
||||
publishVisible.value = false;
|
||||
draftTenantId.value = '';
|
||||
pendingTenantId.value = '';
|
||||
form.requestId = '';
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
}
|
||||
scopeTenantId.value = resolvedTenantId;
|
||||
restorePendingPublish();
|
||||
messages.value = response.data?.rows ?? [];
|
||||
total.value = response.data?.total ?? 0;
|
||||
} catch {
|
||||
messages.value = [];
|
||||
total.value = 0;
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = () => {
|
||||
if (publishing.value) return;
|
||||
pageNum.value = 1;
|
||||
void loadMessages();
|
||||
};
|
||||
|
||||
const openPublish = () => {
|
||||
if (publishing.value || publishPending.value) return;
|
||||
if (!storageAvailable.value) {
|
||||
ElMessage.error('浏览器存储不可用,无法安全发起公司消息发布。');
|
||||
return;
|
||||
}
|
||||
if (!scopeTenantId.value) {
|
||||
ElMessage.warning('正在确认当前租户,请稍候。');
|
||||
return;
|
||||
}
|
||||
if (restorePendingPublish()) {
|
||||
ElMessage.warning('检测到同一租户存在结果未确认的发布,请先重试或明确放弃。');
|
||||
return;
|
||||
}
|
||||
if (!storageAvailable.value) return;
|
||||
form.requestId = createRequestId();
|
||||
draftTenantId.value = scopeTenantId.value;
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
publishVisible.value = true;
|
||||
};
|
||||
|
||||
const resetPublishForm = () => {
|
||||
if (publishPending.value && !clearPendingPublish()) return false;
|
||||
publishPending.value = false;
|
||||
draftTenantId.value = '';
|
||||
form.requestId = '';
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
publishFormRef.value?.clearValidate();
|
||||
return true;
|
||||
};
|
||||
|
||||
const confirmDiscardPendingPublish = async () => {
|
||||
if (!publishPending.value) return true;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'上一次发布结果尚未确认。继续重试会复用同一请求;放弃后若该请求其实已成功,重新发布可能产生另一条消息。',
|
||||
'确认放弃未确认发布?',
|
||||
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续重试', type: 'warning' }
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestClosePublish = async () => {
|
||||
if (publishing.value || !(await confirmDiscardPendingPublish())) return;
|
||||
if (!resetPublishForm()) return;
|
||||
publishVisible.value = false;
|
||||
};
|
||||
|
||||
const beforeClosePublish = (done: () => void) => {
|
||||
void (async () => {
|
||||
if (publishing.value || !(await confirmDiscardPendingPublish())) return;
|
||||
if (!resetPublishForm()) return;
|
||||
done();
|
||||
})();
|
||||
};
|
||||
|
||||
const submitPublish = async () => {
|
||||
if (!publishFormRef.value) return;
|
||||
const valid = await publishFormRef.value
|
||||
.validate()
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!valid) return;
|
||||
if (!scopeTenantId.value) {
|
||||
ElMessage.warning('当前租户尚未确认,请刷新页面后重试。');
|
||||
return;
|
||||
}
|
||||
if (!draftTenantId.value || draftTenantId.value !== scopeTenantId.value) {
|
||||
ElMessage.warning('当前租户已变化,已取消这条未发布草稿;请刷新后重新填写。');
|
||||
publishVisible.value = false;
|
||||
form.requestId = '';
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
draftTenantId.value = '';
|
||||
return;
|
||||
}
|
||||
const otherPending = readPendingPublish();
|
||||
if (!storageAvailable.value) return;
|
||||
if (otherPending && otherPending.requestId !== form.requestId) {
|
||||
applyPendingPublish(otherPending);
|
||||
ElMessage.warning('检测到另一窗口存在结果未确认的发布,请先重试或明确放弃。');
|
||||
return;
|
||||
}
|
||||
if (!form.requestId) form.requestId = createRequestId();
|
||||
form.title = form.title.trim();
|
||||
form.content = form.content.trim();
|
||||
publishPending.value = true;
|
||||
if (!persistPendingPublish()) {
|
||||
publishPending.value = false;
|
||||
return;
|
||||
}
|
||||
publishing.value = true;
|
||||
try {
|
||||
await publishBroadcast({
|
||||
requestId: form.requestId,
|
||||
title: form.title,
|
||||
content: form.content,
|
||||
expectedTenantId: pendingTenantId.value
|
||||
});
|
||||
if (!resetPublishForm()) {
|
||||
ElMessage.warning('消息已发布,但浏览器无法清理重试记录;请不要新建消息,先恢复浏览器存储后重试原请求。');
|
||||
return;
|
||||
}
|
||||
publishVisible.value = false;
|
||||
ElMessage.success('消息已发布');
|
||||
pageNum.value = 1;
|
||||
await loadMessages(true);
|
||||
} catch {
|
||||
ElMessage.error('发布结果暂未确认,请使用“重试发布”复用原请求,或确认放弃。');
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (message: BroadcastAdminMessage) => {
|
||||
let reason = '';
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(`撤回“${message.title}”后,员工端将不再展示该消息。请填写撤回原因。`, '确认撤回', {
|
||||
confirmButtonText: '确认撤回',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'textarea',
|
||||
inputPlaceholder: '例如:内容需要修订后重新发布',
|
||||
inputValidator: (value) => {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) return '请填写撤回原因';
|
||||
if (normalized.length > 500) return '撤回原因不能超过 500 个字符';
|
||||
return true;
|
||||
},
|
||||
type: 'warning'
|
||||
});
|
||||
reason = String(result.value ?? '').trim();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
withdrawingId.value = message.id;
|
||||
try {
|
||||
if (!scopeTenantId.value) {
|
||||
ElMessage.warning('当前租户尚未确认,请刷新页面后重试。');
|
||||
return;
|
||||
}
|
||||
await withdrawBroadcast(message.id, { reason, expectedTenantId: scopeTenantId.value });
|
||||
ElMessage.success('消息已撤回');
|
||||
await loadMessages();
|
||||
} finally {
|
||||
withdrawingId.value = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadMessages();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.broadcast-page {
|
||||
min-height: calc(100vh - 84px);
|
||||
padding: 24px 28px 40px;
|
||||
background: #f4f6fa;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.section-title,
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.section-title {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
color: #858991;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.data-alert,
|
||||
.publish-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
|
||||
.section-title {
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p,
|
||||
.muted {
|
||||
color: #858991;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.audit-info,
|
||||
.withdraw-info {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
|
||||
small {
|
||||
color: #858991;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audit-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
:deep(.el-table th.el-table__cell) {
|
||||
color: #667085;
|
||||
font-weight: 500;
|
||||
background: #fafbfc;
|
||||
}
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
flex: none;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 126px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-head,
|
||||
.section-title {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.broadcast-page {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+728
-612
File diff suppressed because it is too large
Load Diff
@@ -7,18 +7,39 @@ const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ..
|
||||
|
||||
describe('knowledge space administration contract', () => {
|
||||
it('registers the controlled route and sidebar entry', () => {
|
||||
expect(src('router', 'index.ts')).toContain("path: '/knowledge/spaces'");
|
||||
const sidebar = src('layout', 'components', 'Sidebar', 'index.vue');
|
||||
expect(src('router', 'index.ts')).toContain("path: '/knowledge'");
|
||||
expect(src('router', 'index.ts')).toContain("path: 'spaces'");
|
||||
expect(src('router', 'index.ts')).toContain("title: '知识空间'");
|
||||
expect(src('layout', 'components', 'Sidebar', 'index.vue')).toContain("'知识空间'");
|
||||
expect(sidebar).toContain('sidebarGroups');
|
||||
expect(sidebar).toContain('visibleChildren(parent).map');
|
||||
expect(sidebar).toContain("import { constantRoutes } from '@/router';");
|
||||
expect(sidebar).toContain('operationsConsoleRoles');
|
||||
expect(sidebar).toContain('<router-link');
|
||||
expect(sidebar).not.toContain('<el-menu-item');
|
||||
});
|
||||
|
||||
it('contains spaces grants and applications tabs without exposing token hash', () => {
|
||||
it('contains spaces, category maintenance, grants and applications without exposing token hash', () => {
|
||||
const page = src('views', 'knowledge', 'spaces.vue');
|
||||
expect(page).toContain('label="知识空间"');
|
||||
expect(page).toContain('label="分类与知识"');
|
||||
expect(page).toContain('label="空间授权"');
|
||||
expect(page).toContain('label="调用应用"');
|
||||
expect(page).toContain('分类只用于当前租户、当前知识空间内的内容维护');
|
||||
expect(page).toContain('assignDocumentCategory(row)');
|
||||
expect(page).toContain('仅显示一次');
|
||||
expect(page).toContain('租户上下文尚未确认');
|
||||
expect(page).toContain('requiresTenantContextConfirmation');
|
||||
expect(page).toContain('ensureTenantContextConfirmed');
|
||||
expect(page).toContain(':disabled="requiresTenantContextConfirmation"');
|
||||
expect(page).toContain('if (!ensureTenantContextConfirmed()) return;');
|
||||
expect(page).toContain('if (!ensureTenantContextConfirmed()) {\n await loadCategoryWorkspace();');
|
||||
expect(src('api', 'aihr', 'space.ts')).not.toContain('tokenHash');
|
||||
expect(src('api', 'aihr', 'space.ts')).toContain('listKnowledgeCategories');
|
||||
expect(src('api', 'aihr', 'space.ts')).toContain('assignSpaceDocumentCategory');
|
||||
expect(page).toContain('const spaceId = categorySpaceId.value');
|
||||
expect(page).toContain('if (categorySpaceId.value !== spaceId) return');
|
||||
expect(page).toContain("error !== 'cancel' && error !== 'close'");
|
||||
});
|
||||
|
||||
it('unbinds only the selected document-space membership', () => {
|
||||
|
||||
@@ -5,12 +5,27 @@
|
||||
<h1>知识空间</h1>
|
||||
<p>按租户管理知识边界、访问主体和调用应用;空间编码创建后不可修改。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadAll">刷新</el-button>
|
||||
<div class="page-head__actions">
|
||||
<el-tag v-if="dynamicTenantId" type="warning" effect="plain"
|
||||
>已切换租户:{{ dynamicTenantName || dynamicTenantId }}({{ dynamicTenantId }})</el-tag
|
||||
>
|
||||
<el-button :loading="loading" @click="loadAll">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert
|
||||
v-if="requiresTenantContextConfirmation"
|
||||
class="context-warning"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="租户上下文尚未确认"
|
||||
description="为防止知识写入错误租户,请刷新页面或从租户管理重新选择租户后再操作。"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<el-tabs v-model="activeTab" class="space-tabs">
|
||||
<el-tab-pane label="知识空间" name="spaces">
|
||||
<div class="toolbar"><el-button type="danger" @click="openSpace()">新建空间</el-button></div>
|
||||
<div class="toolbar"><el-button type="danger" :disabled="requiresTenantContextConfirmation" @click="openSpace()">新建空间</el-button></div>
|
||||
<el-table :data="spaces" v-loading="loading">
|
||||
<el-table-column prop="name" label="空间名称" min-width="170" />
|
||||
<el-table-column prop="code" label="稳定编码" min-width="180" />
|
||||
@@ -19,7 +34,12 @@
|
||||
<el-table-column prop="documentCount" label="文档" width="80" />
|
||||
<el-table-column prop="fragmentCount" label="片段" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="openSpace(row)">编辑</el-button><el-button link type="primary" @click="openDocuments(row)">文档成员</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="180"
|
||||
><template #default="{ row }"
|
||||
><el-button link type="primary" :disabled="requiresTenantContextConfirmation" @click="openSpace(row)">编辑</el-button
|
||||
><el-button link type="primary" @click="openDocuments(row)">文档成员</el-button></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -28,55 +48,223 @@
|
||||
<el-select v-model="grantSpaceId" placeholder="选择知识空间" style="width: 280px" @change="loadGrants">
|
||||
<el-option v-for="space in spaces" :key="space.id" :label="`${space.name} (${space.code})`" :value="space.id" />
|
||||
</el-select>
|
||||
<el-button :disabled="!grantSpaceId" @click="addGrant">新增授权</el-button>
|
||||
<el-button type="danger" :disabled="!grantSpaceId" :loading="saving" @click="saveGrants">保存全部授权</el-button>
|
||||
<el-button :disabled="!grantSpaceId || requiresTenantContextConfirmation" @click="addGrant">新增授权</el-button>
|
||||
<el-button type="danger" :disabled="!grantSpaceId || requiresTenantContextConfirmation" :loading="saving" @click="saveGrants"
|
||||
>保存全部授权</el-button
|
||||
>
|
||||
</div>
|
||||
<el-table :data="grants">
|
||||
<el-table-column label="主体类型" width="150"><template #default="{ row }"><el-select v-model="row.principalType"><el-option label="角色" value="ROLE" /><el-option label="用户" value="USER" /></el-select></template></el-table-column>
|
||||
<el-table-column label="角色编码 / 用户ID" min-width="220"><template #default="{ row }"><el-input v-model="row.principalValue" placeholder="如 employee / 10001" /></template></el-table-column>
|
||||
<el-table-column label="权限" width="150"><template #default="{ row }"><el-select v-model="row.permission"><el-option label="读取" value="READ" /><el-option label="管理" value="MANAGE" /></el-select></template></el-table-column>
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-select v-model="row.status"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></template></el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" @click="grants.splice($index, 1)">删除</el-button></template></el-table-column>
|
||||
<el-table-column label="主体类型" width="150"
|
||||
><template #default="{ row }"
|
||||
><el-select v-model="row.principalType" :disabled="requiresTenantContextConfirmation"
|
||||
><el-option label="角色" value="ROLE" /><el-option label="用户" value="USER" /></el-select></template
|
||||
></el-table-column>
|
||||
<el-table-column label="角色编码 / 用户ID" min-width="220"
|
||||
><template #default="{ row }"
|
||||
><el-input v-model="row.principalValue" :disabled="requiresTenantContextConfirmation" placeholder="如 employee / 10001" /></template
|
||||
></el-table-column>
|
||||
<el-table-column label="权限" width="150"
|
||||
><template #default="{ row }"
|
||||
><el-select v-model="row.permission" :disabled="requiresTenantContextConfirmation"
|
||||
><el-option label="读取" value="READ" /><el-option label="管理" value="MANAGE" /></el-select></template
|
||||
></el-table-column>
|
||||
<el-table-column label="状态" width="150"
|
||||
><template #default="{ row }"
|
||||
><el-select v-model="row.status" :disabled="requiresTenantContextConfirmation"
|
||||
><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></template
|
||||
></el-table-column>
|
||||
<el-table-column label="操作" width="80"
|
||||
><template #default="{ $index }"
|
||||
><el-button link type="danger" :disabled="requiresTenantContextConfirmation" @click="removeGrant($index)">删除</el-button></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="分类与知识" name="categories">
|
||||
<el-alert
|
||||
class="category-hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="分类只用于当前租户、当前知识空间内的内容维护,不改变空间授权和调用应用可检索范围。"
|
||||
/>
|
||||
<div class="toolbar">
|
||||
<el-select v-model="categorySpaceId" placeholder="选择知识空间" style="width: 300px" @change="loadCategoryWorkspace">
|
||||
<el-option v-for="space in spaces" :key="space.id" :label="`${space.name} (${space.code})`" :value="space.id" />
|
||||
</el-select>
|
||||
<el-button type="danger" :disabled="!categorySpaceId || requiresTenantContextConfirmation" @click="openCategory()">新建分类</el-button>
|
||||
</div>
|
||||
<el-row :gutter="16" class="category-layout">
|
||||
<el-col :xs="24" :lg="10">
|
||||
<section class="category-panel">
|
||||
<h2>空间分类</h2>
|
||||
<el-table :data="categories" v-loading="categoryLoading" max-height="460">
|
||||
<el-table-column prop="name" label="分类名称" min-width="140" />
|
||||
<el-table-column prop="code" label="稳定编码" min-width="145" />
|
||||
<el-table-column prop="documentCount" label="知识数" width="86" />
|
||||
<el-table-column label="状态" width="92"
|
||||
><template #default="{ row }"
|
||||
><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" size="small">{{
|
||||
row.status === 'ACTIVE' ? '启用' : '停用'
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
>
|
||||
<el-table-column label="操作" width="130"
|
||||
><template #default="{ row }"
|
||||
><el-button link type="primary" :disabled="requiresTenantContextConfirmation" @click="openCategory(row)">编辑</el-button
|
||||
><el-button link type="danger" :disabled="row.documentCount > 0 || requiresTenantContextConfirmation" @click="deleteCategory(row)"
|
||||
>删除</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
</section>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="14">
|
||||
<section class="category-panel">
|
||||
<h2>知识归类</h2>
|
||||
<el-table :data="categoryDocuments" v-loading="categoryLoading" max-height="460">
|
||||
<el-table-column prop="name" label="文件名" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="92" />
|
||||
<el-table-column prop="fragmentCount" label="片段" width="76" />
|
||||
<el-table-column label="所属分类" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.categoryId"
|
||||
clearable
|
||||
placeholder="未分类"
|
||||
:disabled="!categorySpaceId || requiresTenantContextConfirmation"
|
||||
@change="assignDocumentCategory(row)"
|
||||
>
|
||||
<el-option
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:label="category.name"
|
||||
:value="category.id"
|
||||
:disabled="category.status !== 'ACTIVE' && row.categoryId !== category.id"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="调用应用" name="apps">
|
||||
<div class="toolbar"><el-button type="danger" @click="openApp()">新建应用</el-button></div>
|
||||
<div class="toolbar"><el-button type="danger" :disabled="requiresTenantContextConfirmation" @click="openApp()">新建应用</el-button></div>
|
||||
<el-table :data="apps" v-loading="loading">
|
||||
<el-table-column prop="appName" label="应用名称" min-width="160" />
|
||||
<el-table-column prop="appCode" label="应用编码" min-width="160" />
|
||||
<el-table-column prop="authType" label="认证方式" width="130" />
|
||||
<el-table-column label="授权空间" min-width="230"><template #default="{ row }">{{ row.spaceCodes.join('、') || '未绑定' }}</template></el-table-column>
|
||||
<el-table-column label="授权空间" min-width="230"
|
||||
><template #default="{ row }">{{ row.spaceCodes.join('、') || '未绑定' }}</template></el-table-column
|
||||
>
|
||||
<el-table-column prop="rateLimitPerMinute" label="每分钟限流" width="110" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column label="操作" width="190"><template #default="{ row }"><el-button link type="primary" @click="openApp(row)">编辑</el-button><el-button v-if="row.authType === 'API_TOKEN'" link type="danger" @click="rotateToken(row)">轮换令牌</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="190"
|
||||
><template #default="{ row }"
|
||||
><el-button link type="primary" :disabled="requiresTenantContextConfirmation" @click="openApp(row)">编辑</el-button
|
||||
><el-button
|
||||
v-if="row.authType === 'API_TOKEN'"
|
||||
link
|
||||
type="danger"
|
||||
:disabled="requiresTenantContextConfirmation"
|
||||
@click="rotateToken(row)"
|
||||
>轮换令牌</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="spaceDialog" :title="spaceEditingId ? '编辑知识空间' : '新建知识空间'" width="560px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="稳定编码"><el-input v-model="spaceForm.code" :disabled="Boolean(spaceEditingId)" placeholder="如 yc_property_sop" /></el-form-item>
|
||||
<el-form-item label="空间名称"><el-input v-model="spaceForm.name" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="spaceForm.description" type="textarea" /></el-form-item>
|
||||
<el-form-item label="类型"><el-select v-model="spaceForm.spaceType"><el-option v-for="value in spaceTypes" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="密级"><el-select v-model="spaceForm.sensitivityLevel"><el-option v-for="value in sensitivities" :key="value" :label="value" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="spaceForm.status"><el-option label="草稿" value="DRAFT" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
|
||||
<el-form-item label="稳定编码"
|
||||
><el-input
|
||||
v-model="spaceForm.code"
|
||||
:disabled="Boolean(spaceEditingId) || requiresTenantContextConfirmation"
|
||||
placeholder="如 yc_property_sop"
|
||||
/></el-form-item>
|
||||
<el-form-item label="空间名称"><el-input v-model="spaceForm.name" :disabled="requiresTenantContextConfirmation" /></el-form-item>
|
||||
<el-form-item label="说明"
|
||||
><el-input v-model="spaceForm.description" :disabled="requiresTenantContextConfirmation" type="textarea"
|
||||
/></el-form-item>
|
||||
<el-form-item label="类型"
|
||||
><el-select v-model="spaceForm.spaceType" :disabled="requiresTenantContextConfirmation"
|
||||
><el-option v-for="value in spaceTypes" :key="value" :label="value" :value="value" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="密级"
|
||||
><el-select v-model="spaceForm.sensitivityLevel" :disabled="requiresTenantContextConfirmation"
|
||||
><el-option v-for="value in sensitivities" :key="value" :label="value" :value="value" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="状态"
|
||||
><el-select v-model="spaceForm.status" :disabled="requiresTenantContextConfirmation"
|
||||
><el-option label="草稿" value="DRAFT" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select
|
||||
></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="spaceDialog = false">取消</el-button><el-button type="danger" :loading="saving" @click="saveSpace">保存</el-button></template>
|
||||
<template #footer
|
||||
><el-button @click="spaceDialog = false">取消</el-button
|
||||
><el-button type="danger" :disabled="requiresTenantContextConfirmation" :loading="saving" @click="saveSpace">保存</el-button></template
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="categoryDialog" :title="categoryEditingId ? '编辑分类' : '新建分类'" width="520px">
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="稳定编码"
|
||||
><el-input
|
||||
v-model="categoryForm.code"
|
||||
:disabled="Boolean(categoryEditingId) || requiresTenantContextConfirmation"
|
||||
placeholder="如 resident_service"
|
||||
/></el-form-item>
|
||||
<el-form-item label="分类名称"
|
||||
><el-input v-model="categoryForm.name" :disabled="requiresTenantContextConfirmation" placeholder="如 业主服务"
|
||||
/></el-form-item>
|
||||
<el-form-item label="排序"
|
||||
><el-input-number v-model="categoryForm.sortOrder" :disabled="requiresTenantContextConfirmation" :min="0" :max="9999"
|
||||
/></el-form-item>
|
||||
<el-form-item label="状态"
|
||||
><el-select v-model="categoryForm.status" :disabled="requiresTenantContextConfirmation"
|
||||
><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select
|
||||
></el-form-item>
|
||||
</el-form>
|
||||
<template #footer
|
||||
><el-button @click="categoryDialog = false">取消</el-button
|
||||
><el-button type="danger" :disabled="requiresTenantContextConfirmation" :loading="saving" @click="saveCategory">保存</el-button></template
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="appDialog" :title="appEditingId ? '编辑调用应用' : '新建调用应用'" width="620px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="应用编码"><el-input v-model="appForm.appCode" :disabled="Boolean(appEditingId)" /></el-form-item>
|
||||
<el-form-item label="应用名称"><el-input v-model="appForm.appName" /></el-form-item>
|
||||
<el-form-item label="认证方式"><el-select v-model="appForm.authType" :disabled="Boolean(appEditingId)"><el-option label="内部登录态" value="SESSION" /><el-option label="外部令牌" value="API_TOKEN" /></el-select></el-form-item>
|
||||
<el-form-item v-if="appForm.authType === 'SESSION'" label="客户端标识"><el-input v-model="appForm.internalClientKey" placeholder="pc / app" /></el-form-item>
|
||||
<el-form-item label="授权空间"><el-select v-model="appForm.spaceCodes" multiple style="width: 100%"><el-option v-for="space in activeSpaces" :key="space.code" :label="space.name" :value="space.code" /></el-select></el-form-item>
|
||||
<el-form-item label="每分钟限流"><el-input-number v-model="appForm.rateLimitPerMinute" :min="1" :max="10000" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="appForm.status"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
|
||||
<el-form-item label="应用编码"
|
||||
><el-input v-model="appForm.appCode" :disabled="Boolean(appEditingId) || requiresTenantContextConfirmation"
|
||||
/></el-form-item>
|
||||
<el-form-item label="应用名称"><el-input v-model="appForm.appName" :disabled="requiresTenantContextConfirmation" /></el-form-item>
|
||||
<el-form-item label="认证方式"
|
||||
><el-select v-model="appForm.authType" :disabled="Boolean(appEditingId) || requiresTenantContextConfirmation"
|
||||
><el-option label="内部登录态" value="SESSION" /><el-option label="外部令牌" value="API_TOKEN" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item v-if="appForm.authType === 'SESSION'" label="客户端标识"
|
||||
><el-input v-model="appForm.internalClientKey" :disabled="requiresTenantContextConfirmation" placeholder="pc / app"
|
||||
/></el-form-item>
|
||||
<el-form-item label="授权空间"
|
||||
><el-select v-model="appForm.spaceCodes" :disabled="requiresTenantContextConfirmation" multiple style="width: 100%"
|
||||
><el-option v-for="space in activeSpaces" :key="space.code" :label="space.name" :value="space.code" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="每分钟限流"
|
||||
><el-input-number v-model="appForm.rateLimitPerMinute" :disabled="requiresTenantContextConfirmation" :min="1" :max="10000"
|
||||
/></el-form-item>
|
||||
<el-form-item label="状态"
|
||||
><el-select v-model="appForm.status" :disabled="requiresTenantContextConfirmation"
|
||||
><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select
|
||||
></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="closeAppDialog">取消</el-button><el-button type="danger" :loading="saving" @click="saveApp">保存</el-button></template>
|
||||
<template #footer
|
||||
><el-button @click="closeAppDialog">取消</el-button
|
||||
><el-button type="danger" :disabled="requiresTenantContextConfirmation" :loading="saving" @click="saveApp">保存</el-button></template
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="tokenDialog" title="访问令牌(仅显示一次)" width="620px" :close-on-click-modal="false" @closed="issuedToken = ''">
|
||||
@@ -86,13 +274,23 @@
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="documentDialog" :title="`${documentSpace?.name || ''} · 文档成员`" width="840px">
|
||||
<el-alert type="info" :closable="false" title="解绑只移除当前空间的文档成员和检索片段;其他空间仍可使用同一原始文件。仅当没有任何空间再引用时,系统才删除 OSS 文件。" />
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="解绑只移除当前空间的文档成员和检索片段;其他空间仍可使用同一原始文件。仅当没有任何空间再引用时,系统才删除 OSS 文件。"
|
||||
/>
|
||||
<el-table :data="documents" v-loading="documentLoading" class="document-table">
|
||||
<el-table-column prop="name" label="文件名" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="type" label="类型" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column prop="fragmentCount" label="片段" width="80" />
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="danger" @click="unbindDocument(row)">解除当前空间绑定</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" width="150"
|
||||
><template #default="{ row }"
|
||||
><el-button link type="danger" :disabled="requiresTenantContextConfirmation" @click="unbindDocument(row)"
|
||||
>解除当前空间绑定</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="documentDialog = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
@@ -103,11 +301,36 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import {
|
||||
createKnowledgeApp, createKnowledgeSpace, listKnowledgeApps, listKnowledgeSpaces, listSpaceDocuments, listSpaceGrants,
|
||||
replaceKnowledgeAppSpaces, replaceSpaceGrants, rotateKnowledgeAppToken, unbindSpaceDocument, updateKnowledgeApp, updateKnowledgeSpace,
|
||||
type AppForm, type KnowledgeApp, type KnowledgeDocument, type KnowledgeSpace, type SpaceForm, type SpaceGrant
|
||||
assignSpaceDocumentCategory,
|
||||
createKnowledgeApp,
|
||||
createKnowledgeCategory,
|
||||
createKnowledgeSpace,
|
||||
deleteKnowledgeCategory,
|
||||
listKnowledgeApps,
|
||||
listKnowledgeCategories,
|
||||
listKnowledgeSpaces,
|
||||
listSpaceDocuments,
|
||||
listSpaceGrants,
|
||||
replaceKnowledgeAppSpaces,
|
||||
replaceSpaceGrants,
|
||||
rotateKnowledgeAppToken,
|
||||
unbindSpaceDocument,
|
||||
updateKnowledgeApp,
|
||||
updateKnowledgeCategory,
|
||||
updateKnowledgeSpace,
|
||||
type AppForm,
|
||||
type CategoryForm,
|
||||
type KnowledgeApp,
|
||||
type KnowledgeCategory,
|
||||
type KnowledgeDocument,
|
||||
type KnowledgeSpace,
|
||||
type SpaceForm,
|
||||
type SpaceGrant
|
||||
} from '@/api/aihr/space';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { dynamicTenantId, dynamicTenantName, tenantContextVerified } from '@/utils/tenant-context';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const activeTab = ref('spaces');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@@ -115,22 +338,45 @@ const spaces = ref<KnowledgeSpace[]>([]);
|
||||
const apps = ref<KnowledgeApp[]>([]);
|
||||
const grants = ref<SpaceGrant[]>([]);
|
||||
const grantSpaceId = ref<number>();
|
||||
const categorySpaceId = ref<number>();
|
||||
const categories = ref<KnowledgeCategory[]>([]);
|
||||
const categoryDocuments = ref<KnowledgeDocument[]>([]);
|
||||
const spaceDialog = ref(false);
|
||||
const categoryDialog = ref(false);
|
||||
const appDialog = ref(false);
|
||||
const tokenDialog = ref(false);
|
||||
const documentDialog = ref(false);
|
||||
const documentLoading = ref(false);
|
||||
const categoryLoading = ref(false);
|
||||
const issuedToken = ref('');
|
||||
const documents = ref<KnowledgeDocument[]>([]);
|
||||
const documentSpace = ref<KnowledgeSpace>();
|
||||
const spaceEditingId = ref<number>();
|
||||
const categoryEditingId = ref<number>();
|
||||
const appEditingId = ref<number>();
|
||||
const spaceTypes = ['PUBLIC', 'BUSINESS', 'MANAGEMENT', 'EXTERNAL'] as const;
|
||||
const sensitivities = ['PUBLIC', 'INTERNAL', 'CONFIDENTIAL'] as const;
|
||||
const activeSpaces = computed(() => spaces.value.filter((item) => item.status === 'ACTIVE'));
|
||||
const requiresTenantContextConfirmation = computed(() => userStore.roles.includes('superadmin') && !tenantContextVerified.value);
|
||||
function ensureTenantContextConfirmed() {
|
||||
if (!requiresTenantContextConfirmation.value) return true;
|
||||
ElMessage.warning('租户上下文尚未确认,暂不能进行知识维护操作');
|
||||
return false;
|
||||
}
|
||||
const blankSpace = (): SpaceForm => ({ code: '', name: '', description: '', spaceType: 'BUSINESS', sensitivityLevel: 'INTERNAL', status: 'DRAFT' });
|
||||
const blankApp = (): AppForm => ({ appCode: '', appName: '', authType: 'SESSION', internalClientKey: 'pc', status: 'ACTIVE', rateLimitPerMinute: 60, expiresTime: null, spaceCodes: [] });
|
||||
const blankCategory = (): CategoryForm => ({ code: '', name: '', status: 'ACTIVE', sortOrder: 0 });
|
||||
const blankApp = (): AppForm => ({
|
||||
appCode: '',
|
||||
appName: '',
|
||||
authType: 'SESSION',
|
||||
internalClientKey: 'pc',
|
||||
status: 'ACTIVE',
|
||||
rateLimitPerMinute: 60,
|
||||
expiresTime: null,
|
||||
spaceCodes: []
|
||||
});
|
||||
const spaceForm = reactive<SpaceForm>(blankSpace());
|
||||
const categoryForm = reactive<CategoryForm>(blankCategory());
|
||||
const appForm = reactive<AppForm>(blankApp());
|
||||
|
||||
async function loadAll() {
|
||||
@@ -139,22 +385,51 @@ async function loadAll() {
|
||||
const [spaceResult, appResult] = await Promise.all([listKnowledgeSpaces(), listKnowledgeApps()]);
|
||||
spaces.value = spaceResult.data || [];
|
||||
apps.value = appResult.data || [];
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { loading.value = false; }
|
||||
if (categorySpaceId.value && !spaces.value.some((space) => space.id === categorySpaceId.value)) {
|
||||
categorySpaceId.value = undefined;
|
||||
categories.value = [];
|
||||
categoryDocuments.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openSpace(row?: KnowledgeSpace) {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
spaceEditingId.value = row?.id;
|
||||
Object.assign(spaceForm, row ? { code: row.code, name: row.name, description: row.description, spaceType: row.spaceType, sensitivityLevel: row.sensitivityLevel, status: row.status } : blankSpace());
|
||||
Object.assign(
|
||||
spaceForm,
|
||||
row
|
||||
? {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
spaceType: row.spaceType,
|
||||
sensitivityLevel: row.sensitivityLevel,
|
||||
status: row.status
|
||||
}
|
||||
: blankSpace()
|
||||
);
|
||||
spaceDialog.value = true;
|
||||
}
|
||||
|
||||
async function saveSpace() {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (spaceEditingId.value) await updateKnowledgeSpace(spaceEditingId.value, spaceForm);
|
||||
else await createKnowledgeSpace(spaceForm);
|
||||
spaceDialog.value = false; ElMessage.success('知识空间已保存'); await loadAll();
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
spaceDialog.value = false;
|
||||
ElMessage.success('知识空间已保存');
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDocuments(space: KnowledgeSpace) {
|
||||
@@ -166,46 +441,183 @@ async function openDocuments(space: KnowledgeSpace) {
|
||||
async function loadDocuments() {
|
||||
if (!documentSpace.value) return;
|
||||
documentLoading.value = true;
|
||||
try { documents.value = (await listSpaceDocuments(documentSpace.value.id)).data || []; }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
finally { documentLoading.value = false; }
|
||||
try {
|
||||
documents.value = (await listSpaceDocuments(documentSpace.value.id)).data || [];
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
} finally {
|
||||
documentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unbindDocument(document: KnowledgeDocument) {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
if (!documentSpace.value) return;
|
||||
await ElMessageBox.confirm(
|
||||
`确认将“${document.name}”从“${documentSpace.value.name}”解绑?其他空间的同一文件不会删除。`,
|
||||
'解除当前空间绑定',
|
||||
{ type: 'warning', confirmButtonText: '确认解绑' }
|
||||
);
|
||||
await ElMessageBox.confirm(`确认将“${document.name}”从“${documentSpace.value.name}”解绑?其他空间的同一文件不会删除。`, '解除当前空间绑定', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认解绑'
|
||||
});
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
try {
|
||||
await unbindSpaceDocument(documentSpace.value.id, document.attachId);
|
||||
ElMessage.success('已解除当前空间绑定');
|
||||
await Promise.all([loadDocuments(), loadAll()]);
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategoryWorkspace() {
|
||||
const spaceId = categorySpaceId.value;
|
||||
if (!spaceId) {
|
||||
categories.value = [];
|
||||
categoryDocuments.value = [];
|
||||
categoryLoading.value = false;
|
||||
return;
|
||||
}
|
||||
categoryLoading.value = true;
|
||||
try {
|
||||
const [categoryResult, documentResult] = await Promise.all([listKnowledgeCategories(spaceId), listSpaceDocuments(spaceId)]);
|
||||
if (categorySpaceId.value !== spaceId) return;
|
||||
categories.value = categoryResult.data || [];
|
||||
categoryDocuments.value = documentResult.data || [];
|
||||
} catch (error) {
|
||||
if (categorySpaceId.value === spaceId) ElMessage.error(errorMessage(error));
|
||||
} finally {
|
||||
if (categorySpaceId.value === spaceId) categoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCategory(row?: KnowledgeCategory) {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
if (!categorySpaceId.value) {
|
||||
ElMessage.warning('请先选择知识空间');
|
||||
return;
|
||||
}
|
||||
categoryEditingId.value = row?.id;
|
||||
Object.assign(
|
||||
categoryForm,
|
||||
row
|
||||
? {
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
sortOrder: row.sortOrder
|
||||
}
|
||||
: blankCategory()
|
||||
);
|
||||
categoryDialog.value = true;
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
if (!categorySpaceId.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (categoryEditingId.value) await updateKnowledgeCategory(categorySpaceId.value, categoryEditingId.value, categoryForm);
|
||||
else await createKnowledgeCategory(categorySpaceId.value, categoryForm);
|
||||
categoryDialog.value = false;
|
||||
ElMessage.success('分类已保存');
|
||||
await loadCategoryWorkspace();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory(category: KnowledgeCategory) {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
if (!categorySpaceId.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除分类“${category.name}”?分类下的知识需要先转移或设为未分类。`, '删除分类', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除'
|
||||
});
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
await deleteKnowledgeCategory(categorySpaceId.value, category.id);
|
||||
ElMessage.success('分类已删除');
|
||||
await loadCategoryWorkspace();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function assignDocumentCategory(document: KnowledgeDocument) {
|
||||
if (!ensureTenantContextConfirmed()) {
|
||||
await loadCategoryWorkspace();
|
||||
return;
|
||||
}
|
||||
if (!categorySpaceId.value) return;
|
||||
try {
|
||||
await assignSpaceDocumentCategory(categorySpaceId.value, document.attachId, document.categoryId);
|
||||
document.categoryName = categories.value.find((category) => category.id === document.categoryId)?.name || null;
|
||||
ElMessage.success('知识分类已更新');
|
||||
await loadCategoryWorkspace();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
await loadCategoryWorkspace();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGrants() {
|
||||
if (!grantSpaceId.value) return;
|
||||
try { grants.value = (await listSpaceGrants(grantSpaceId.value)).data || []; } catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
try {
|
||||
grants.value = (await listSpaceGrants(grantSpaceId.value)).data || [];
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
function addGrant() { grants.value.push({ principalType: 'ROLE', principalValue: '', permission: 'READ', status: 'ACTIVE' }); }
|
||||
function addGrant() {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
grants.value.push({ principalType: 'ROLE', principalValue: '', permission: 'READ', status: 'ACTIVE' });
|
||||
}
|
||||
function removeGrant(index: number) {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
grants.value.splice(index, 1);
|
||||
}
|
||||
async function saveGrants() {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
if (!grantSpaceId.value) return;
|
||||
saving.value = true;
|
||||
try { grants.value = (await replaceSpaceGrants(grantSpaceId.value, grants.value)).data || []; ElMessage.success('空间授权已保存'); }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
try {
|
||||
grants.value = (await replaceSpaceGrants(grantSpaceId.value, grants.value)).data || [];
|
||||
ElMessage.success('空间授权已保存');
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openApp(row?: KnowledgeApp) {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
appEditingId.value = row?.id;
|
||||
Object.assign(appForm, row ? { appCode: row.appCode, appName: row.appName, authType: row.authType, internalClientKey: row.internalClientKey, status: row.status, rateLimitPerMinute: row.rateLimitPerMinute, expiresTime: row.expiresTime, spaceCodes: [...row.spaceCodes] } : blankApp());
|
||||
Object.assign(
|
||||
appForm,
|
||||
row
|
||||
? {
|
||||
appCode: row.appCode,
|
||||
appName: row.appName,
|
||||
authType: row.authType,
|
||||
internalClientKey: row.internalClientKey,
|
||||
status: row.status,
|
||||
rateLimitPerMinute: row.rateLimitPerMinute,
|
||||
expiresTime: row.expiresTime,
|
||||
spaceCodes: [...row.spaceCodes]
|
||||
}
|
||||
: blankApp()
|
||||
);
|
||||
appDialog.value = true;
|
||||
}
|
||||
|
||||
function closeAppDialog() { appDialog.value = false; Object.assign(appForm, blankApp()); }
|
||||
function closeAppDialog() {
|
||||
appDialog.value = false;
|
||||
Object.assign(appForm, blankApp());
|
||||
}
|
||||
async function saveApp() {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (appEditingId.value) {
|
||||
@@ -215,26 +627,105 @@ async function saveApp() {
|
||||
const result = await createKnowledgeApp(appForm);
|
||||
if (result.data.token) showIssuedToken(result.data.token);
|
||||
}
|
||||
closeAppDialog(); ElMessage.success('调用应用已保存'); await loadAll();
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); } finally { saving.value = false; }
|
||||
closeAppDialog();
|
||||
ElMessage.success('调用应用已保存');
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateToken(row: KnowledgeApp) {
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
await ElMessageBox.confirm('轮换后旧令牌立即失效,是否继续?', '轮换访问令牌', { type: 'warning' });
|
||||
try { showIssuedToken((await rotateKnowledgeAppToken(row.id, row.expiresTime)).data.token); }
|
||||
catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
if (!ensureTenantContextConfirmed()) return;
|
||||
try {
|
||||
showIssuedToken((await rotateKnowledgeAppToken(row.id, row.expiresTime)).data.token);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
function showIssuedToken(token: string) { issuedToken.value = token; tokenDialog.value = true; }
|
||||
function errorMessage(error: unknown) { return error instanceof Error && error.message ? error.message : '操作失败,请检查权限或参数'; }
|
||||
function showIssuedToken(token: string) {
|
||||
issuedToken.value = token;
|
||||
tokenDialog.value = true;
|
||||
}
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error && error.message ? error.message : '操作失败,请检查权限或参数';
|
||||
}
|
||||
onMounted(loadAll);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.space-page { min-height: calc(100vh - 84px); padding: 22px; background: #f5f7fb; color: #162033; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 16px; h1 { margin: 0 0 8px; font-size: 24px; } p { margin: 0; color: #657184; } }
|
||||
.space-tabs { padding: 16px 18px; border: 1px solid #e4e9f2; border-radius: 10px; background: #fff; }
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 14px; }
|
||||
.token-box { margin-top: 16px; }
|
||||
.document-table { margin-top: 14px; }
|
||||
.space-page {
|
||||
min-height: calc(100vh - 84px);
|
||||
padding: 22px;
|
||||
background: #f5f7fb;
|
||||
color: #162033;
|
||||
}
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
color: #657184;
|
||||
}
|
||||
}
|
||||
.page-head__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.context-warning {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.space-tabs {
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #e4e9f2;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.token-box {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.document-table {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.category-hint {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.category-layout {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.category-panel {
|
||||
min-height: 290px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e8edf5;
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
.category-panel h2 {
|
||||
margin: 0 0 12px;
|
||||
color: #27364a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.category-panel {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<div class="tenant-governance p-2">
|
||||
<section class="tenant-governance__head">
|
||||
<div>
|
||||
<h1>租户管理</h1>
|
||||
<p>维护租户主体,并按“租户 → 知识空间 → 分类 → 知识”进入内容治理。</p>
|
||||
</div>
|
||||
<div class="tenant-governance__hint">知识空间、分类和知识仍按当前切换租户隔离管理。</div>
|
||||
</section>
|
||||
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
|
||||
<div v-show="showSearch" class="mb-[10px]">
|
||||
<el-card shadow="hover">
|
||||
@@ -72,8 +79,18 @@
|
||||
<el-switch v-model="scope.row.status" active-value="0" inactive-value="1" @change="handleStatusChange(scope.row)"></el-switch>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="150" label="操作" align="center" fixed="right" class-name="small-padding fixed-width">
|
||||
<el-table-column width="230" label="操作" align="center" fixed="right" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPermi="['system:tenant:edit']"
|
||||
link
|
||||
type="primary"
|
||||
:loading="tenantContextSwitching"
|
||||
:disabled="tenantContextSwitching || scope.row.status !== '0'"
|
||||
:title="scope.row.status === '0' ? '进入此租户的知识维护' : '停用租户不能维护知识'"
|
||||
@click="manageKnowledge(scope.row)"
|
||||
>知识维护</el-button
|
||||
>
|
||||
<el-tooltip content="修改" placement="top">
|
||||
<el-button v-hasPermi="['system:tenant:edit']" link type="primary" icon="Edit" @click="handleUpdate(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
@@ -160,6 +177,7 @@ import {
|
||||
} from '@/api/system/tenant';
|
||||
import { selectTenantPackage } from '@/api/system/tenantPackage';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { switchDynamicTenantContext, tenantContextSwitching } from '@/utils/tenant-context';
|
||||
import { TenantForm, TenantQuery, TenantVO } from '@/api/system/tenant/types';
|
||||
import { TenantPkgVO } from '@/api/system/tenantPackage/types';
|
||||
|
||||
@@ -232,6 +250,21 @@ const data = reactive<PageData<TenantForm, TenantQuery>>({
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data);
|
||||
|
||||
/** 切换到目标租户后再进入空间、分类和文档维护。后端以动态租户上下文为准。 */
|
||||
const manageKnowledge = async (row: TenantVO) => {
|
||||
if (!row.tenantId || tenantContextSwitching.value) return;
|
||||
if (row.status !== '0') {
|
||||
proxy?.$modal.msgError('停用租户不能维护知识');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await switchDynamicTenantContext({ tenantId: row.tenantId, companyName: row.companyName }, '/knowledge/spaces');
|
||||
proxy?.$modal.msgSuccess(`已切换至“${row.companyName}”,可继续维护知识空间、分类和知识`);
|
||||
} catch (error) {
|
||||
proxy?.$modal.msgError(error instanceof Error && error.message ? error.message : '租户切换失败,请刷新后重试');
|
||||
}
|
||||
};
|
||||
|
||||
/** 查询所有租户套餐 */
|
||||
const getTenantPackage = async () => {
|
||||
const res = await selectTenantPackage();
|
||||
@@ -380,3 +413,43 @@ onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tenant-governance__head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 2px 16px;
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
color: #162033;
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #657184;
|
||||
}
|
||||
}
|
||||
|
||||
.tenant-governance__hint {
|
||||
max-width: 420px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dbe7fb;
|
||||
border-radius: 8px;
|
||||
color: #39699f;
|
||||
background: #f4f8ff;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.tenant-governance__head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -294,7 +294,7 @@ import { UserForm, UserQuery, UserVO } from '@/api/system/user/types';
|
||||
import { DeptTreeVO, DeptVO } from '@/api/system/dept/types';
|
||||
import { RoleVO } from '@/api/system/role/types';
|
||||
import { PostVO } from '@/api/system/post/types';
|
||||
import { globalHeaders } from '@/utils/request';
|
||||
import { globalHeaders, reconcileTenantContextResponse } from '@/utils/request';
|
||||
import { to } from 'await-to-js';
|
||||
import { optionselect } from '@/api/system/post';
|
||||
import { checkPermi } from '@/utils/permission';
|
||||
@@ -332,6 +332,9 @@ const upload = reactive<ImportOption>({
|
||||
// 上传的地址
|
||||
url: import.meta.env.VITE_APP_BASE_API + '/system/user/importData'
|
||||
});
|
||||
watchEffect(() => {
|
||||
upload.headers = globalHeaders();
|
||||
});
|
||||
// 列显隐信息
|
||||
const columns = ref<FieldOption[]>([
|
||||
{ key: 0, label: `用户编号`, visible: false, children: [] },
|
||||
@@ -570,13 +573,14 @@ const handleFileUploadProgress = () => {
|
||||
};
|
||||
/** 文件上传成功处理 */
|
||||
const handleFileSuccess = (response: any, file: UploadFile) => {
|
||||
const tenantContextChanged = reconcileTenantContextResponse(response);
|
||||
upload.open = false;
|
||||
upload.isUploading = false;
|
||||
uploadRef.value?.handleRemove(file);
|
||||
ElMessageBox.alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + '</div>', '导入结果', {
|
||||
dangerouslyUseHTMLString: true
|
||||
});
|
||||
getList();
|
||||
if (!tenantContextChanged) getList();
|
||||
};
|
||||
|
||||
/** 提交上传文件 */
|
||||
@@ -615,9 +619,7 @@ const handleUpdate = async (row?: UserForm) => {
|
||||
dialog.title = '修改用户';
|
||||
Object.assign(form.value, data.user);
|
||||
postOptions.value = data.posts;
|
||||
roleOptions.value = Array.from(
|
||||
new Map([...data.roles, ...data.user.roles].map(role => [role.roleId, role])).values()
|
||||
);
|
||||
roleOptions.value = Array.from(new Map([...data.roles, ...data.user.roles].map((role) => [role.roleId, role])).values());
|
||||
form.value.postIds = data.postIds;
|
||||
form.value.roleIds = data.roleIds;
|
||||
form.value.password = '';
|
||||
|
||||
Reference in New Issue
Block a user