feat(company): 公司列表编辑改抽屉 + 公司详情展示已启用应用
CrudFormPanel 支持 mode=drawer(CrudTable 新增 formMode prop 透传,默认仍是居中弹窗,避免表格嵌在弹窗里的场景叠抽屉);Company 列表页启用 form-mode=drawer。 公司详情页新增「已启用应用」卡片(仅管理员可见):列出该公司 InstallStatus=1 的应用,支持卸载(停用本公司安装,不动租户级/其它公司)与配置(跳 /app/config 并带 CompanyUUID 定位本公司范围)。新增 appApi.companyApps;AppConfigPage 支持 CompanyUUID 查询参数预选范围。
This commit is contained in:
1 parent
2d989d5af1
commit
bb25b6f4d6
6 files changed
+213
-4
No files matched your search
@@ -146,6 +146,13 @@ export const appApi = {
|
||||
AppUUID ? { AppUUID } : {},
|
||||
),
|
||||
|
||||
/**
|
||||
* 指定公司已启用的应用列表(公司详情页用);
|
||||
* 权限:管理员 + 该公司在可管理范围内(super=全部 / tenant=本租户 / company=被授权公司)
|
||||
*/
|
||||
companyApps: (CompanyUUID: string): Promise<{ list: AppItem[]; total: number }> =>
|
||||
get<{ list: AppItem[]; total: number }>('/API/App/CompanyApps', { CompanyUUID }),
|
||||
|
||||
/** 管理员:安装企业功能(不传 CompanyUUIDs=全租户;传 CompanyUUIDs=指定公司);
|
||||
* GrantRoles=RAM 角色粒度授权(可选):指定角色可见该应用(安装范围内) */
|
||||
install: (AppUUID: string, CompanyUUIDs?: string[], GrantRoles?: string[]): Promise<AppItem> =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElDialog, ElDrawer } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import type { ColumnSchema } from '@/types/api'
|
||||
import EntityPicker from './EntityPicker.vue'
|
||||
@@ -25,6 +26,12 @@ const props = defineProps<{
|
||||
/** 表单数据对象(引用:直接改属性,父组件 editingRow) */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 对齐 useCrudTable 的 editingRow 类型
|
||||
formModel: Record<string, any>
|
||||
/**
|
||||
* 表单容器形态:
|
||||
* - 'dialog'(默认):居中弹窗;适用于 CrudTable 自身嵌在弹窗里的场景(弹窗内不宜再开抽屉)
|
||||
* - 'drawer':右侧抽屉;列表页"编辑"用(由 CrudTable 的 formMode 透传)
|
||||
*/
|
||||
mode?: 'dialog' | 'drawer'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -42,6 +49,9 @@ const emit = defineEmits<{
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
/** 是否抽屉形态(列表页编辑);默认居中弹窗(表格嵌在弹窗内的场景不适用抽屉) */
|
||||
const isDrawer = computed(() => props.mode === 'drawer')
|
||||
|
||||
// required 列自动生成校验规则(required 为样式标记 → 真实校验)
|
||||
const rules = computed<FormRules>(() => {
|
||||
const r: FormRules = {}
|
||||
@@ -129,10 +139,12 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
<!-- 容器:dialog(默认,适用于表格内嵌弹窗场景)/ drawer(列表页编辑抽屉) -->
|
||||
<component
|
||||
:is="isDrawer ? ElDrawer : ElDialog"
|
||||
:model-value="visible"
|
||||
:title="title"
|
||||
width="600px"
|
||||
v-bind="isDrawer ? { direction: 'rtl', size: 600 } : { width: '600px' }"
|
||||
destroy-on-close
|
||||
@update:model-value="(v: boolean) => emit('update:visible', v)"
|
||||
>
|
||||
@@ -290,5 +302,5 @@ watch(
|
||||
<el-button @click="emit('update:visible', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</component>
|
||||
</template>
|
||||
@@ -86,6 +86,12 @@ const props = withDefaults(defineProps<{
|
||||
* (用于 role_scope 非管理员场景,如 Products/Orders 仅管理员可改)。
|
||||
*/
|
||||
editable?: boolean
|
||||
/**
|
||||
* 新增/编辑表单容器形态(默认 'dialog' 居中弹窗):
|
||||
* 传 'drawer' 时改为右侧抽屉(列表页编辑建议用抽屉)。
|
||||
* 注意:表格自身嵌在弹窗里时不要用抽屉(弹窗内再叠抽屉会错位)。
|
||||
*/
|
||||
formMode?: 'dialog' | 'drawer'
|
||||
}>(), {
|
||||
// editable 必须显式给默认值 true:Vue 会把「未传的 Boolean prop」强制转成 false,
|
||||
// 不给默认值时 props.editable 恒为 false,canEdit 判定失败 → 所有未传 editable 的
|
||||
|
||||
@@ -19,6 +19,8 @@ import { setPageTitle } from '@/utils/serverScope'
|
||||
const route = useRoute()
|
||||
const AppUUID = computed(() => (route.query.AppUUID as string) || '')
|
||||
const AppName = computed(() => (route.query.AppName as string) || '')
|
||||
/** URL 指定公司(公司详情页「已启用应用 → 配置」入口):优先定位到该公司范围 */
|
||||
const QueryCompanyUUID = computed(() => (route.query.CompanyUUID as string) || '')
|
||||
|
||||
setPageTitle('应用配置')
|
||||
|
||||
@@ -61,6 +63,12 @@ async function initScope(): Promise<void> {
|
||||
const r = await appApi.manageCompanies()
|
||||
companies.value = r.Companies || []
|
||||
scope.value = r.Scope || ''
|
||||
// URL 指定公司(公司详情页进入):在可管理范围内则直接定位到该公司
|
||||
const target = QueryCompanyUUID.value
|
||||
if (target && companies.value.some((c) => c.CompanyUUID === target)) {
|
||||
currentCompany.value = target
|
||||
return
|
||||
}
|
||||
if (scope.value === 'company') {
|
||||
// 公司管理员:默认本公司(第一个可管理公司)
|
||||
currentCompany.value = companies.value[0]?.CompanyUUID || ''
|
||||
|
||||
@@ -99,6 +99,7 @@ async function handleIconChange(event: Event): Promise<void> {
|
||||
searchable
|
||||
stretch
|
||||
selectable
|
||||
form-mode="drawer"
|
||||
card-mode
|
||||
card-title-field="CompanyName"
|
||||
card-icon-field="CompanyIcon"
|
||||
|
||||
@@ -24,12 +24,15 @@
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Grid } from '@element-plus/icons-vue'
|
||||
import DetailPage from '@/components/DetailPage.vue'
|
||||
import CrudTable from '@/components/CrudTable.vue'
|
||||
import EntityPicker from '@/components/EntityPicker.vue'
|
||||
import BatchPositionDialog from '@/components/BatchPositionDialog.vue'
|
||||
import { formApi } from '@/api/form'
|
||||
import { appApi, type AppItem } from '@/api/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { upload } from '@/api/request'
|
||||
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
|
||||
import { setPageTitle } from '@/utils/serverScope'
|
||||
@@ -53,6 +56,7 @@ type RowData = Record<string, unknown>
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { resolveUUIDs } = useResolveUUIDs()
|
||||
|
||||
/** 加载态 */
|
||||
@@ -250,6 +254,59 @@ async function saveEdit(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 已启用应用(公司维度:安装记录 InstallStatus=1)=====
|
||||
/**
|
||||
* 仅管理员可见(后端 /API/App/CompanyApps 按可管理范围校验:
|
||||
* super=全部 / tenant=本租户 / company=被授权公司),非管理员不发请求。
|
||||
*/
|
||||
const companyApps = ref<AppItem[]>([])
|
||||
const appsLoading = ref(false)
|
||||
|
||||
async function loadCompanyApps(): Promise<void> {
|
||||
if (!userStore.isAdmin || !companyUUID.value) {
|
||||
companyApps.value = []
|
||||
return
|
||||
}
|
||||
appsLoading.value = true
|
||||
try {
|
||||
const r = await appApi.companyApps(companyUUID.value)
|
||||
companyApps.value = r.list || []
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出(无权/网络异常时按空列表展示)
|
||||
companyApps.value = []
|
||||
} finally {
|
||||
appsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开应用配置页:带上 CompanyUUID 让配置页直接定位到本公司范围 */
|
||||
function openAppConfig(a: AppItem): void {
|
||||
router.push({
|
||||
path: '/app/config',
|
||||
query: { AppUUID: a.AppUUID, AppName: a.AppName, CompanyUUID: companyUUID.value },
|
||||
})
|
||||
}
|
||||
|
||||
/** 卸载:停用本公司的安装记录(租户级安装与其它公司安装不受影响) */
|
||||
async function uninstallApp(a: AppItem): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认在本公司停用「${a.AppName}」?停用后本公司用户将不可使用该应用(租户级安装与其它公司不受影响)。`,
|
||||
'确认停用',
|
||||
{ type: 'warning', confirmButtonText: '停用', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
try {
|
||||
await appApi.uninstall(a.AppUUID, [companyUUID.value])
|
||||
ElMessage.success('已停用')
|
||||
await loadCompanyApps()
|
||||
} catch {
|
||||
// 错误提示已由响应拦截器统一弹出
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCompany)
|
||||
|
||||
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧公司数据 / 缺参报错)
|
||||
@@ -257,6 +314,7 @@ onMounted(loadCompany)
|
||||
// 空值场景静默显示空态(loadCompany 内处理),不再弹警告
|
||||
watch(companyUUID, () => {
|
||||
void loadCompany()
|
||||
void loadCompanyApps()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -326,6 +384,45 @@ watch(companyUUID, () => {
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 已启用应用(公司维度的安装记录):仅管理员可见,可卸载/配置 -->
|
||||
<el-card v-if="userStore.isAdmin" shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="detail-card-header">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>已启用应用</span>
|
||||
<el-tag v-if="companyApps.length" size="small" type="success" effect="plain">
|
||||
{{ companyApps.length }}
|
||||
</el-tag>
|
||||
<el-button class="card-header-action" link type="primary" size="small" @click="router.push('/app/market')">
|
||||
去应用市场
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-loading="appsLoading" class="company-apps">
|
||||
<el-empty
|
||||
v-if="!appsLoading && !companyApps.length"
|
||||
description="该公司暂无已启用应用,可到应用市场安装"
|
||||
:image-size="70"
|
||||
/>
|
||||
<template v-else>
|
||||
<div v-for="a in companyApps" :key="a.AppUUID" class="company-app-item">
|
||||
<div class="company-app-icon">
|
||||
<img v-if="a.AppIcon" :src="a.AppIcon" :alt="a.AppName" />
|
||||
<el-icon v-else :size="20"><Grid /></el-icon>
|
||||
</div>
|
||||
<div class="company-app-main">
|
||||
<div class="company-app-name">{{ a.AppName || a.AppCode }}</div>
|
||||
<div class="company-app-desc">{{ a.AppDesc || a.AppCode }}</div>
|
||||
</div>
|
||||
<div class="company-app-actions">
|
||||
<el-button size="small" type="info" plain @click="openAppConfig(a)">配置</el-button>
|
||||
<el-button size="small" type="danger" plain @click="uninstallApp(a)">卸载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 子表 1:部门 -->
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
@@ -642,6 +739,84 @@ watch(companyUUID, () => {
|
||||
// ===== 子表卡片 =====
|
||||
// 使用全局 .detail-card / .detail-card-header(见 global.scss),此处无需重复定义
|
||||
|
||||
// ===== 已启用应用(公司维度) =====
|
||||
// 卡片头部右侧动作按钮(对齐右侧)
|
||||
.card-header-action {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// 应用列表(加载态用 v-loading,故需最小高度避免抖动)
|
||||
.company-apps {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
// 单行应用:图标 + 名称/描述 + 操作
|
||||
.company-app-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 4px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.company-app-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.company-app-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.company-app-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.company-app-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.company-app-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
// 间距统一由 gap 控制(抵消 Element 相邻按钮的 margin-left)
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 编辑弹窗图标上传 =====
|
||||
.edit-icon-row {
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user