feat: 完成客户端联调与通用列表组件

This commit is contained in:
2026-08-01 12:17:33 +08:00
parent be7675954a
commit beb86d53e7
31 changed files with 1326 additions and 378 deletions
+63 -51
View File
@@ -1,31 +1,53 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { LockKeyhole, Pencil, Plus, Search, ShieldCheck, Trash2, Users } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { LockKeyhole, Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useTenantAdministrationStore, type TenantRole } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const router = useRouter()
const keyword = ref('')
const roleFilters = ref({ keyword: '' })
const appliedRoleFilters = ref({ ...roleFilters.value })
const rolePage = ref(1)
const rolePageSize = ref(10)
const editorOpen = ref(false)
const editingId = ref('')
const loading = ref(false)
const loadError = ref('')
const saving = ref(false)
const actionRoleId = ref('')
const formRef = ref<FormInstance>()
const form = reactive({ name: '', code: '', description: '' })
const roleRules: FormRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
description: [{ required: true, message: '请填写角色说明', trigger: 'blur' }],
}
const roleQueryFields: QueryField[] = [{ key: 'keyword', type: 'input', placeholder: '搜索角色名称、编码或说明' }]
const roleTableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 250, slotName: 'actions' },
{ key: 'role', label: '角色', minWidth: 180, slotName: 'role' },
{ key: 'description', prop: 'description', label: '角色说明', minWidth: 220 },
{ key: 'memberCount', label: '成员数', width: 100, slotName: 'memberCount' },
{ key: 'permissionCount', label: '权限数', width: 100, slotName: 'permissionCount' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
]
const filteredRoles = computed(() => {
const key = keyword.value.trim().toLowerCase()
const key = appliedRoleFilters.value.keyword.trim().toLowerCase()
return store.roles.filter((role) => !key || [role.name, role.code, role.description].some((value) => value.toLowerCase().includes(key)))
})
const pagedRoles = computed(() => filteredRoles.value.slice((rolePage.value - 1) * rolePageSize.value, rolePage.value * rolePageSize.value))
function resetForm() {
editingId.value = ''
Object.assign(form, { name: '', code: '', description: '' })
void nextTick(() => formRef.value?.clearValidate())
}
function openCreate() {
@@ -55,11 +77,19 @@ async function loadRoles() {
onMounted(() => void loadRoles())
function searchRoles(filters: QueryModel) {
appliedRoleFilters.value = { keyword: typeof filters.keyword === 'string' ? filters.keyword : '' }
rolePage.value = 1
void loadRoles()
}
async function saveRole() {
if (!form.name || !form.code || !form.description) {
ElMessage.warning('请完整填写角色名称、编码和说明')
return
}
if (saving.value) return
form.name = form.name.trim()
form.code = form.code.trim().toUpperCase()
form.description = form.description.trim()
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingId.value) await store.updateRole(editingId.value, { name: form.name, description: form.description })
@@ -114,28 +144,26 @@ function assignPermissions(role: TenantRole) {
<el-button :loading="loading" @click="loadRoles">重新加载</el-button>
</div>
<template v-else>
<div class="role-guidance client-card"><ShieldCheck :size="22" /><div><strong>角色用于约束企业内部成员可见菜单和可执行操作</strong><span>企业管理员为系统内置角色,不能停用或删除;平台后台权限不会出现在客户工作台。</span></div></div>
<QueryToolbar v-model="roleFilters" ariaLabel="企业角色筛选" :fields="roleQueryFields" search-text="查询角色" @search="searchRoles">
<template #actions><el-button v-if="auth.hasPermission('company:role:write')" type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button></template>
</QueryToolbar>
<div class="role-toolbar client-card">
<el-input v-model="keyword" clearable placeholder="搜索角色名称、编码或说明"><template #prefix><Search :size="16" /></template></el-input>
<el-button v-if="auth.hasPermission('company:role:write')" type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button>
<div class="client-card role-table-card">
<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize" :data="pagedRoles" :columns="roleTableColumns" :loading="loading" :total="filteredRoles.length" row-key="id">
<template #actions="{ row }"><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="assignPermissions(row as TenantRole)">{{ auth.hasPermission('company:permission:assign') && !(row as TenantRole).builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="openEdit(row as TenantRole)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link :type="(row as TenantRole).status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === (row as TenantRole).id" @click="toggleStatus(row as TenantRole)">{{ (row as TenantRole).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="danger" :disabled="actionRoleId === (row as TenantRole).id" @click="removeRole(row as TenantRole)"><Trash2 :size="15" />删除</el-button></template>
<template #role="{ row }"><div class="role-identity"><span class="role-icon"><LockKeyhole v-if="(row as TenantRole).builtin" :size="20" /><ShieldCheck v-else :size="20" /></span><div><strong>{{ (row as TenantRole).name }}</strong><code>{{ (row as TenantRole).code }}</code></div></div></template>
<template #memberCount="{ row }"><span>{{ store.roleMemberCount((row as TenantRole).id) }}</span></template>
<template #permissionCount="{ row }"><span>{{ (row as TenantRole).permissionCodes.length }}</span></template>
<template #status="{ row }"><el-tag v-if="(row as TenantRole).builtin" effect="plain">内置</el-tag><el-tag :type="(row as TenantRole).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as TenantRole).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #empty><div class="client-empty">没有匹配的企业角色。</div></template>
</DataTable>
</div>
<div v-if="filteredRoles.length" class="role-grid">
<article v-for="role in filteredRoles" :key="role.id" class="client-card role-card" :class="{ inactive: role.status === 'INACTIVE' }">
<header><div class="role-icon"><LockKeyhole v-if="role.builtin" :size="20" /><ShieldCheck v-else :size="20" /></div><div class="role-heading"><div><h3>{{ role.name }}</h3><el-tag v-if="role.builtin" effect="plain">内置</el-tag><el-tag :type="role.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ role.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></div><code>{{ role.code }}</code></div></header>
<p>{{ role.description }}</p>
<dl><div><dt><Users :size="15" />成员数</dt><dd>{{ store.roleMemberCount(role.id) }}</dd></div><div><dt><ShieldCheck :size="15" />权限数</dt><dd>{{ role.permissionCodes.length }}</dd></div></dl>
<footer><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === role.id" @click="assignPermissions(role)">{{ auth.hasPermission('company:permission:assign') && !role.builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !role.builtin" link type="primary" :disabled="actionRoleId === role.id" @click="openEdit(role)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:role:write') && !role.builtin" link :type="role.status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === role.id" @click="toggleStatus(role)">{{ role.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !role.builtin" link type="danger" :disabled="actionRoleId === role.id" @click="removeRole(role)"><Trash2 :size="15" />删除</el-button></footer>
</article>
</div>
<div v-else class="client-card client-empty">没有匹配的企业角色。</div>
<el-drawer v-model="editorOpen" :title="editingId ? '编辑角色' : '新增角色'" size="min(500px, 94vw)" @closed="resetForm">
<el-form label-position="top">
<el-form-item label="角色名称" required><el-input v-model="form.name" maxlength="30" /></el-form-item>
<el-form-item label="角色编码" required><el-input v-model="form.code" :disabled="Boolean(editingId)" placeholder="例如 ORDER_OPERATOR" maxlength="40" /></el-form-item>
<el-form-item label="角色说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="200" show-word-limit /></el-form-item>
<el-form ref="formRef" :model="form" :rules="roleRules" label-position="top">
<el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" maxlength="30" placeholder="例如 订单操作员" /></el-form-item>
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(editingId)" placeholder="例如 ORDER_OPERATOR" maxlength="40" /></el-form-item>
<el-form-item label="角色说明" prop="description" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="简要说明该角色的职责和适用范围" /></el-form-item>
<div class="role-form-tip">保存后请前往“菜单权限”为角色分配可见菜单和操作权限。</div>
</el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRole">保存</el-button></div></template>
@@ -146,33 +174,17 @@ function assignPermissions(role: TenantRole) {
<style scoped>
.roles-page { display: flex; flex-direction: column; gap: 14px; }
.role-guidance { display: flex; align-items: center; gap: 13px; padding: 17px 20px; border-left: 4px solid var(--primary); color: var(--primary); }
.role-guidance > div { display: flex; flex-direction: column; gap: 4px; }
.role-guidance strong { color: var(--navy); font-size: 13px; }
.role-guidance span { color: var(--muted); font-size: 11px; }
.collection-load-error { display: flex; min-height: 150px; align-items: center; justify-content: center; gap: 20px; }
.collection-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
.collection-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.role-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; }
.role-toolbar .el-input { max-width: 360px; }
.role-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.role-card { display: flex; min-width: 0; flex-direction: column; padding: 20px; }
.role-card.inactive { opacity: .72; }
.role-card header { justify-content: flex-start; gap: 12px; }
.role-icon { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: #e7f3f1; color: var(--primary); }
.role-heading { min-width: 0; }
.role-heading > div { display: flex; align-items: center; gap: 7px; }
.role-heading h3 { margin: 0; font-size: 16px; }
.role-heading code { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; }
.role-card > p { flex: 1; min-height: 42px; margin: 18px 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
.role-card dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; border: 1px solid var(--border); }
.role-card dl div { display: flex; align-items: center; justify-content: space-between; padding: 11px 13px; }
.role-card dl div:first-child { border-right: 1px solid var(--border); }
.role-card dt { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; }
.role-card dd { margin: 0; color: var(--navy); font-weight: 750; }
.role-card footer { display: flex; justify-content: flex-end; gap: 4px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); flex-wrap: wrap; }
.role-table-card { padding: 0; overflow: hidden; }
.role-identity { display: flex; min-width: 0; align-items: center; gap: 10px; }
.role-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; background: #e7f3f1; color: var(--primary); }
.role-identity > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.role-identity strong { overflow-wrap: anywhere; }
.role-identity code { color: var(--muted); font-size: 10px; }
.role-form-tip { padding: 12px; background: #f2f7f6; color: var(--muted); font-size: 11px; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
@media (max-width: 800px) { .role-grid { grid-template-columns: 1fr; } }
@media (max-width: 600px) { .role-toolbar { align-items: stretch; flex-direction: column; } .role-toolbar .el-input { max-width: none; } .role-toolbar .el-button { width: 100%; } }
.roles-page :deep(.el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
@media (max-width: 600px) { .drawer-actions { flex-wrap: wrap; } }
</style>