497 lines
12 KiB
Vue
497 lines
12 KiB
Vue
<script setup lang="ts">
|
||
import { computed, reactive, watch } from 'vue'
|
||
|
||
import type { DocumentRecord, TemplatePermissionRecord } from '@/api/management/types'
|
||
|
||
import type { PermissionSubjectOption, TemplatePermissionForm } from '../types'
|
||
|
||
const props = defineProps<{
|
||
templates: DocumentRecord[]
|
||
selectedTemplateId: string
|
||
permissions: TemplatePermissionRecord[]
|
||
subjects: PermissionSubjectOption[]
|
||
loading: boolean
|
||
saving: boolean
|
||
error: boolean
|
||
errorMessage?: string
|
||
catalogError?: string
|
||
catalogLoading?: boolean
|
||
previewUserId: string
|
||
canEdit?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'update:selectedTemplateId': [value: string]
|
||
'update:previewUserId': [value: string]
|
||
add: [form: TemplatePermissionForm]
|
||
delete: [permissionId: string]
|
||
retry: []
|
||
}>()
|
||
|
||
const form = reactive<TemplatePermissionForm>({
|
||
subjectType: 'ROLE',
|
||
subjectId: '',
|
||
permissionLevel: 'VIEW',
|
||
effect: 'ALLOW',
|
||
})
|
||
|
||
const subjectTypeLabels: Record<PermissionSubjectOption['type'], string> = {
|
||
ROLE: '角色',
|
||
DEPARTMENT: '科室',
|
||
USER: '用户',
|
||
}
|
||
|
||
const permissionLabels: Record<TemplatePermissionForm['permissionLevel'], string> = {
|
||
VIEW: '可见(查看)',
|
||
USE: '可用(发起签署)',
|
||
MAINTAIN: '可维护(编辑/停用)',
|
||
}
|
||
|
||
const effectLabels: Record<TemplatePermissionForm['effect'], string> = {
|
||
ALLOW: '允许',
|
||
DENY: '拒绝',
|
||
}
|
||
|
||
const availableSubjects = computed(() =>
|
||
props.subjects.filter((subject) => subject.type === form.subjectType),
|
||
)
|
||
|
||
const previewUsers = computed(() => props.subjects.filter((subject) => subject.type === 'USER'))
|
||
|
||
const selectedTemplate = computed(() =>
|
||
props.templates.find((template) => template.id === props.selectedTemplateId),
|
||
)
|
||
|
||
function handleTemplateChange(event: Event) {
|
||
emit('update:selectedTemplateId', (event.target as HTMLSelectElement).value)
|
||
}
|
||
|
||
function handlePreviewUserChange(event: Event) {
|
||
emit('update:previewUserId', (event.target as HTMLSelectElement).value)
|
||
}
|
||
|
||
function handleSubjectTypeChange() {
|
||
form.subjectId = availableSubjects.value[0]?.id ?? ''
|
||
}
|
||
|
||
function submit() {
|
||
if (!form.subjectId) {
|
||
return
|
||
}
|
||
|
||
emit('add', { ...form })
|
||
}
|
||
|
||
function deletePermission(permissionId: string | undefined) {
|
||
if (permissionId) {
|
||
emit('delete', permissionId)
|
||
}
|
||
}
|
||
|
||
function formatSubject(subjectType: PermissionSubjectOption['type'], subjectId: string | null) {
|
||
if (!subjectId) {
|
||
return '系统默认继承规则'
|
||
}
|
||
|
||
const subject = props.subjects.find((item) => item.type === subjectType && item.id === subjectId)
|
||
return subject?.label ?? subjectId
|
||
}
|
||
|
||
watch(
|
||
() => props.subjects,
|
||
() => {
|
||
if (!availableSubjects.value.some((subject) => subject.id === form.subjectId)) {
|
||
form.subjectId = availableSubjects.value[0]?.id ?? ''
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<section class="permission-panel">
|
||
<div class="panel-toolbar">
|
||
<label v-if="templates.length" class="template-select">
|
||
<span>选择模板</span>
|
||
<select :value="selectedTemplateId" @change="handleTemplateChange">
|
||
<option value="" disabled>请选择模板</option>
|
||
<option v-for="template in templates" :key="template.id" :value="template.id">
|
||
{{ template.name }} · {{ template.version }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<span v-else class="template-empty-label">当前院区暂无可配置模板</span>
|
||
<span v-if="selectedTemplate" class="template-meta">
|
||
{{ selectedTemplate.code || selectedTemplate.id }} · {{ selectedTemplate.category }}
|
||
</span>
|
||
<label class="template-select preview-select">
|
||
<span>继承预览用户</span>
|
||
<select :value="previewUserId" :disabled="loading" @change="handlePreviewUserChange">
|
||
<option value="">当前登录用户</option>
|
||
<option v-for="user in previewUsers" :key="user.id" :value="user.id">
|
||
{{ user.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div v-if="catalogError" class="error-state">
|
||
<span>{{ catalogError }}</span>
|
||
<button type="button" class="retry-button" @click="emit('retry')">重新加载</button>
|
||
</div>
|
||
<div v-else-if="catalogLoading" class="loading-state">正在加载模板目录…</div>
|
||
<div v-else-if="!templates.length" class="empty-state">
|
||
暂无模板例外
|
||
<small>当前院区还没有可配置的文档模板。</small>
|
||
</div>
|
||
<div v-else-if="!selectedTemplateId" class="empty-state">请选择一个模板查看例外权限。</div>
|
||
<div v-else-if="loading" class="loading-state">正在加载模板权限…</div>
|
||
<div v-else-if="error" class="error-state">
|
||
<span>{{ errorMessage || '模板权限加载失败,请稍后重试' }}</span>
|
||
<button type="button" class="retry-button" @click="emit('retry')">重新加载</button>
|
||
</div>
|
||
<template v-else>
|
||
<div class="permission-list">
|
||
<div class="list-heading">
|
||
<span>已配置权限</span>
|
||
<span>{{ permissions.length }} 条</span>
|
||
</div>
|
||
<div v-if="permissions.length" class="table-scroll">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>授权对象</th>
|
||
<th>权限</th>
|
||
<th>效果</th>
|
||
<th>来源</th>
|
||
<th>创建时间</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="permission in permissions"
|
||
:key="
|
||
permission.id ??
|
||
[
|
||
permission.subjectType,
|
||
permission.subjectId,
|
||
permission.permissionLevel,
|
||
permission.effect,
|
||
].join('-')
|
||
"
|
||
>
|
||
<td>
|
||
<span class="subject-type">{{ subjectTypeLabels[permission.subjectType] }}</span>
|
||
{{ formatSubject(permission.subjectType, permission.subjectId) }}
|
||
</td>
|
||
<td>{{ permissionLabels[permission.permissionLevel] }}</td>
|
||
<td>{{ effectLabels[permission.effect] }}</td>
|
||
<td>{{ permission.inherited ? '继承' : '直接' }}</td>
|
||
<td>{{ permission.createdAt }}</td>
|
||
<td>
|
||
<button
|
||
type="button"
|
||
class="delete-button"
|
||
:disabled="permission.inherited || saving || props.canEdit === false"
|
||
:title="
|
||
permission.inherited
|
||
? '继承权限不可直接删除'
|
||
: props.canEdit === false
|
||
? '当前账号无直接权限修改权限'
|
||
: '删除权限'
|
||
"
|
||
@click="deletePermission(permission.id)"
|
||
>
|
||
删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p v-else class="empty-state compact">当前模板暂无直接权限配置。</p>
|
||
</div>
|
||
|
||
<form v-if="props.canEdit !== false" class="add-form" @submit.prevent="submit">
|
||
<div class="list-heading">
|
||
<span>新增权限</span>
|
||
<span>服务端会再次校验授权范围</span>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label>
|
||
<span>对象类型</span>
|
||
<select v-model="form.subjectType" @change="handleSubjectTypeChange">
|
||
<option v-for="(label, value) in subjectTypeLabels" :key="value" :value="value">
|
||
{{ label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>授权对象</span>
|
||
<select v-model="form.subjectId" :disabled="!availableSubjects.length">
|
||
<option value="" disabled>请选择对象</option>
|
||
<option v-for="subject in availableSubjects" :key="subject.id" :value="subject.id">
|
||
{{ subject.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>权限级别</span>
|
||
<select v-model="form.permissionLevel">
|
||
<option v-for="(label, value) in permissionLabels" :key="value" :value="value">
|
||
{{ label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>授权效果</span>
|
||
<select v-model="form.effect">
|
||
<option v-for="(label, value) in effectLabels" :key="value" :value="value">
|
||
{{ label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<button type="submit" class="add-button" :disabled="saving || !form.subjectId">
|
||
{{ saving ? '保存中…' : '添加权限' }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
<p v-else class="readonly-tip">当前账号仅可查看模板例外,新增和删除操作已禁用。</p>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.permission-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.panel-toolbar {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: flex-end;
|
||
flex-wrap: wrap;
|
||
padding-bottom: 12px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
.template-select,
|
||
.form-grid label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.template-select {
|
||
min-width: min(420px, 100%);
|
||
}
|
||
|
||
.preview-select {
|
||
min-width: min(280px, 100%);
|
||
}
|
||
|
||
select {
|
||
min-width: 0;
|
||
padding: 7px 10px;
|
||
color: var(--ink);
|
||
font: inherit;
|
||
background: #fff;
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
select:focus {
|
||
outline: none;
|
||
border-color: var(--brand);
|
||
}
|
||
|
||
select:disabled {
|
||
background: #f5f8f9;
|
||
}
|
||
|
||
.template-meta {
|
||
padding-bottom: 8px;
|
||
color: var(--mut);
|
||
font-size: 11.5px;
|
||
}
|
||
|
||
.template-empty-label {
|
||
padding-bottom: 8px;
|
||
color: var(--mut);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.list-heading {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
color: var(--ink);
|
||
font-size: 12.5px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.list-heading span:last-child {
|
||
color: var(--mut);
|
||
font-size: 11.5px;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.table-scroll {
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
min-width: 760px;
|
||
font-size: 12px;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
th {
|
||
padding: 8px 10px;
|
||
color: var(--mut);
|
||
font-weight: 700;
|
||
text-align: left;
|
||
white-space: nowrap;
|
||
background: #f5f9fa;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
|
||
td {
|
||
padding: 8px 10px;
|
||
color: var(--ink);
|
||
border-bottom: 1px solid #eef4f6;
|
||
}
|
||
|
||
.subject-type {
|
||
display: inline-block;
|
||
padding: 2px 6px;
|
||
margin-right: 5px;
|
||
color: var(--brand);
|
||
font-size: 10.5px;
|
||
background: var(--brand-l);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.delete-button {
|
||
padding: 3px 8px;
|
||
color: var(--err);
|
||
font-size: 11.5px;
|
||
background: #fff;
|
||
border: 1px solid rgb(194 73 73 / 32%);
|
||
border-radius: 5px;
|
||
}
|
||
|
||
.delete-button:disabled {
|
||
color: #aebbc0;
|
||
border-color: #dce5e8;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.add-form {
|
||
padding-top: 14px;
|
||
border-top: 1px solid var(--line);
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
|
||
gap: 10px;
|
||
align-items: end;
|
||
}
|
||
|
||
.add-button {
|
||
padding: 7px 13px;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
background: var(--brand);
|
||
border: 1px solid var(--brand);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.add-button:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.55;
|
||
}
|
||
|
||
.empty-state,
|
||
.loading-state,
|
||
.error-state {
|
||
padding: 32px 16px;
|
||
color: var(--mut);
|
||
font-size: 12.5px;
|
||
text-align: center;
|
||
}
|
||
|
||
.empty-state small {
|
||
display: block;
|
||
margin-top: 5px;
|
||
color: var(--mut);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.error-state {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--err);
|
||
background: #f8fbfc;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.retry-button {
|
||
padding: 4px 10px;
|
||
color: var(--brand);
|
||
font-size: 12px;
|
||
background: #fff;
|
||
border: 1px solid var(--brand);
|
||
border-radius: 5px;
|
||
}
|
||
|
||
.retry-button:hover {
|
||
background: var(--brand-l);
|
||
}
|
||
|
||
.empty-state.compact {
|
||
padding: 18px 12px;
|
||
margin: 0;
|
||
background: #f8fbfc;
|
||
}
|
||
|
||
.readonly-tip {
|
||
padding: 10px 12px;
|
||
margin: 0;
|
||
color: var(--mut);
|
||
font-size: 11.5px;
|
||
background: #f8fbfc;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
@media (max-width: 960px) {
|
||
.form-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.add-button {
|
||
width: 100%;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|