init: UserBaseFront (frontend)
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<AppForm v-model="visible" :initForm="initFormData" :categoryList="categoryList" :isEdit="false"
|
||||
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppForm from "./AppForm.vue";
|
||||
|
||||
/* ---------- 对外暴露:由父组件(AppList)调用 ---------- */
|
||||
const visible = ref(false);
|
||||
|
||||
// 应用类型列表——由父组件通过 expose 传入,或内部自行拉取
|
||||
const categoryList = ref<any[]>([]);
|
||||
|
||||
// 新增模式:始终传空初始值
|
||||
const initFormData = ref<Record<string, any>>({});
|
||||
|
||||
// 成功后回调
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppSettings.addApp({ data: dto }),
|
||||
{ initialForm: { name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success('新增应用成功!');
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event) => {
|
||||
Message.error(event?.error?.message || '新增失败');
|
||||
});
|
||||
|
||||
/* ---------- 打开弹窗 ---------- */
|
||||
function open(categories: any[], defaultCategoryId?: number | null, onSuccess?: () => void) {
|
||||
categoryList.value = categories || [];
|
||||
// 若列表已筛选某应用类型,则新增弹窗自动带入该类型;选「全部」时不带入
|
||||
initFormData.value = defaultCategoryId != null ? { categoryId: defaultCategoryId } : {};
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
form.value = {
|
||||
name: formData.name,
|
||||
categoryId: formData.categoryId,
|
||||
desc: formData.desc,
|
||||
clients: formData.clients,
|
||||
pcUrl: formData.pcUrl,
|
||||
pcIcon: formData.pcIcon,
|
||||
appUrl: formData.appUrl,
|
||||
appIcon: formData.appIcon,
|
||||
officialAccountUrl: formData.officialAccountUrl,
|
||||
dingtalkUrl: formData.dingtalkUrl,
|
||||
};
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="main-container">
|
||||
<Alert show-icon>设置说明:拖动排序,被使用的分类不能删除</Alert>
|
||||
<Button class="mb-5" type="primary" @click="addModuleShow">新增应用类型</Button>
|
||||
<draggable class="type_set-wrap fix" tag="ul" v-model="typeList" :group="'appType'" @change="typeSortSave"
|
||||
item-key="id">
|
||||
<template #item="{ element }">
|
||||
<li class="fl">
|
||||
<div class="type_set-box">
|
||||
<div class="type_name">
|
||||
<span class="content">{{ element.name }}</span>
|
||||
</div>
|
||||
<div class="type_action flex">
|
||||
<div class="action_edit" @click="showSetting(element)">
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
|
||||
<div class="action_delete" @click="removeSave(element)">
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-wrap">
|
||||
<Modal v-model="editModal" :mask-closable="false" width="600"
|
||||
:title="currentId == null ? '新增应用类型' : '编辑应用类型'">
|
||||
<Form ref="formData" class="mt-8 mr-4 mb-12" :model="form" :rules="ruleValidate" :label-width="100">
|
||||
<FormItem label="类型名称:" prop="name">
|
||||
<Input placeholder="请输入应用类型名称" v-model="form.name" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="submitting" @click="onEditSave">提交</Button>
|
||||
<Button @click="onReset">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import draggable from 'vuedraggable'
|
||||
import { debounce } from "lodash";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
|
||||
const { data: typeList, send: sendTypeList } = useRequest(
|
||||
Apis.AppSettings.appTypeList({
|
||||
transform: (res: any) => {
|
||||
return res ?? [];
|
||||
},
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [], immediate: true }
|
||||
)
|
||||
|
||||
const refFormData = useTemplateRef('formData');
|
||||
const editModal = ref(false);
|
||||
const currentId = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const form = ref<{ id: number | null; name: string }>({
|
||||
id: null,
|
||||
name: '',
|
||||
});
|
||||
|
||||
const ruleValidate = ref({
|
||||
name: [{ required: true, message: "请输入应用类型名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
async function addModuleShow() {
|
||||
currentId.value = null;
|
||||
form.value = { id: null, name: '' };
|
||||
await refFormData.value?.resetFields();
|
||||
editModal.value = true;
|
||||
}
|
||||
|
||||
//拖动排序:提交当前顺序的 id 数组
|
||||
async function typeSortSave() {
|
||||
const ids = (typeList.value ?? []).map((s: any) => s.id);
|
||||
await Apis.AppSettings.appTypeSort({ data: ids });
|
||||
}
|
||||
|
||||
async function showSetting(element: any) {
|
||||
currentId.value = element.id;
|
||||
form.value = {
|
||||
id: element.id,
|
||||
name: element.name,
|
||||
};
|
||||
editModal.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeSave
|
||||
*/
|
||||
async function removeSave(element: any) {
|
||||
await MyConfirm({
|
||||
msg: "确定删除该应用类型吗?",
|
||||
info: "删除后不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppSettings.deleteType({ pathParams: { id: element.id } });
|
||||
Message.success("操作成功!");
|
||||
await sendTypeList();
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onEditSave = debounce(onRawEditSave, 500)
|
||||
async function onRawEditSave() {
|
||||
try {
|
||||
const validate = await refFormData.value?.validate();
|
||||
if (!validate) return false;
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (currentId.value == null) {
|
||||
await Apis.AppSettings.addType({
|
||||
data: {
|
||||
name: form.value.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await Apis.AppSettings.editType({
|
||||
pathParams: { id: currentId.value },
|
||||
data: {
|
||||
id: currentId.value,
|
||||
name: form.value.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
Message.success("操作成功!");
|
||||
editModal.value = false;
|
||||
await sendTypeList();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
refFormData.value?.resetFields();
|
||||
currentId.value = null;
|
||||
editModal.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.type_set-wrap {
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.type_set-box {
|
||||
width: 222px;
|
||||
background: #ffffff;
|
||||
margin-right: 15px;
|
||||
margin-bottom: 15px;
|
||||
|
||||
.type_name {
|
||||
height: 66px;
|
||||
border: 1px solid #e9e9e9;
|
||||
border-bottom: none;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
line-height: 66px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: move;
|
||||
|
||||
span.content {
|
||||
height: 24px;
|
||||
font-size: 15px;
|
||||
font-family: PingFangSC-Semibold, PingFang SC;
|
||||
font-weight: 500;
|
||||
color: #272829;
|
||||
line-height: 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 222px;
|
||||
display: inline-block;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.type_action {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 222px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
background: #f7f7f7;
|
||||
border-radius: 0px 0px 2px 2px;
|
||||
border-top: none;
|
||||
border: 1px solid #e9e9e9;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
div:hover {
|
||||
color: #6c91f0;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 14px;
|
||||
width: 1px;
|
||||
background: #e8e8e8;
|
||||
float: left;
|
||||
margin-left: 111px;
|
||||
margin-top: -22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="app-form width-95">
|
||||
<!-- 第一行:应用名称 / 应用类型 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="应用名称:" prop="name">
|
||||
<Input v-model="form.name" placeholder="请输入应用名称" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="应用类型:" prop="categoryId">
|
||||
<Select v-model="form.categoryId" transfer placeholder="请选择应用类型">
|
||||
<Option v-for="item in categoryList" :key="item.id" :value="item.id" :label="item.name" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
<!-- 第二行:应用描述(整行) -->
|
||||
<Row>
|
||||
<Col span="24">
|
||||
<FormItem label="应用描述:" prop="desc">
|
||||
<Input v-model="form.desc" type="text" placeholder="请输入应用描述" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第三行:appKey / appSecret(编辑模式才显示) -->
|
||||
<Row v-if="isEdit">
|
||||
<Col span="12">
|
||||
<FormItem label="appKey:">
|
||||
<Input :model-value="form.appKey?.toString()" readonly />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="appSecret:">
|
||||
<div class="secret-row">
|
||||
<Input :model-value="form.appSecret" readonly style="flex:1" />
|
||||
<Button type="default" class="ml-8" @click="$emit('reset-secret')">重置</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第四行:应用前端类型(tag 多选) -->
|
||||
<Row>
|
||||
<Col span="24">
|
||||
<FormItem label="应用前端类型:">
|
||||
<Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select">
|
||||
<Option v-for="opt in clientOptions" :key="opt.value" :value="opt.value" :label="opt.label" />
|
||||
</Select>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第五行:Pc应用地址 / Pc应用图标 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="Pc应用地址:">
|
||||
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="Pc应用图标:">
|
||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
|
||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||
<div v-if="!form.pcIcon" class="icon-upload-wrap">
|
||||
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
|
||||
</div>
|
||||
<div class="icon-preview-wrap" v-else>
|
||||
<SvgIcon @click.stop="form.pcIcon = null" class="cloase-btn" name="widget-close" size="18"
|
||||
color="#fff" />
|
||||
<img :src="previewUrl(form.pcIcon)" alt="" />
|
||||
</div>
|
||||
</Upload>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第六行:App接口地址 / App应用图标 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="App接口地址:">
|
||||
<Input v-model="form.appUrl" placeholder="请输入App接口地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="App应用图标:">
|
||||
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')" :show-upload-list="false"
|
||||
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
|
||||
:with-credentials="true" :headers="{ yschapirequest: 1 }">
|
||||
<div v-if="!form.appIcon" class="icon-upload-wrap">
|
||||
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
|
||||
</div>
|
||||
<div class="icon-preview-wrap" v-else>
|
||||
<SvgIcon @click.stop="form.appIcon = null" class="cloase-btn" name="widget-close" size="18"
|
||||
color="#fff" />
|
||||
<img :src="previewUrl(form.appIcon)" alt="" />
|
||||
</div>
|
||||
</Upload>
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 第七行:公众号应用地址 / 钉钉应用地址 -->
|
||||
<Row>
|
||||
<Col span="12">
|
||||
<FormItem label="公众号应用地址:">
|
||||
<Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<FormItem label="钉钉应用地址:">
|
||||
<Input v-model="form.dingtalkUrl" placeholder="请输入钉钉应用地址" />
|
||||
</FormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
|
||||
<Button @click="handleCancel">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import { computed, reactive, watch } from "vue";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
const { $bus, $utils, $conf } = useCurrentInstance();
|
||||
/* ---------- Props / Emits ---------- */
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean;
|
||||
initForm?: Record<string, any>;
|
||||
categoryList?: any[];
|
||||
isEdit?: boolean;
|
||||
loading?: boolean;
|
||||
}>(), {
|
||||
initForm: () => ({}),
|
||||
categoryList: () => [],
|
||||
isEdit: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'submit', 'cancel', 'reset-secret']);
|
||||
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: val => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
const title = computed(() => props.isEdit ? '编辑应用' : '新增应用');
|
||||
|
||||
/* ---------- 表单数据 ---------- */
|
||||
const defaultForm = (): Record<string, any> => ({
|
||||
id: null,
|
||||
name: '',
|
||||
categoryId: undefined as number | undefined,
|
||||
desc: '',
|
||||
appKey: null,
|
||||
appSecret: '',
|
||||
clients: [] as number[],
|
||||
pcUrl: '',
|
||||
pcIcon: '',
|
||||
appUrl: '',
|
||||
appIcon: '',
|
||||
officialAccountUrl: '',
|
||||
dingtalkUrl: '',
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
// 外部 initForm 变化时回填
|
||||
watch(() => props.initForm, (val) => {
|
||||
if (val && Object.keys(val).length > 0) {
|
||||
Object.assign(form, defaultForm(), val);
|
||||
} else {
|
||||
Object.assign(form, defaultForm());
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
/* ---------- 上传相关 ---------- */
|
||||
// 上传地址使用 $conf.serviceHost 作为基地址
|
||||
const uploadUrl = computed(() => $conf?.serviceHost + '/api/base/UploadFile');
|
||||
|
||||
function previewUrl(path: string | undefined): string {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
return ($conf?.serviceHost || '') + path;
|
||||
}
|
||||
|
||||
function onUploadSuccess(response: any, field: 'pcIcon' | 'appIcon') {
|
||||
if (response?.code === 0 || response?.code === 200) {
|
||||
(form as any)[field] = response.data ?? response.url ?? '';
|
||||
} else {
|
||||
Message.error((response?.msg || response?.message) || '上传失败');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 前端类型选项 ---------- */
|
||||
const clientOptions = [
|
||||
{ value: 1, label: 'Pc浏览器' },
|
||||
{ value: 2, label: '移动App' },
|
||||
];
|
||||
|
||||
/* ---------- 校验规则 ---------- */
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
|
||||
categoryId: [{ required: true, type: 'number' as const, message: '请选择应用类型', trigger: 'change' }],
|
||||
};
|
||||
|
||||
/* ---------- 提交 ---------- */
|
||||
const formRef = ref();
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 提交前清理空值
|
||||
const submitData = { ...form };
|
||||
emit('submit', submitData);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
function onVisibleChange(val: boolean) {
|
||||
if (!val) {
|
||||
emit('cancel');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 表单整体:宽度 95% 避免横向滚动(width-95 工具类控制宽度,scss 生成) */
|
||||
.app-form {
|
||||
margin: 0 auto;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 12px;
|
||||
|
||||
/* FormItem 改为 block,避免默认 inline-block 在 flex Col 内垂直间距失效 */
|
||||
:deep(.ivu-form-item) {
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ivu-form-item-label) {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
/* 行间距改由 Row 统一控制(Row 是 flex 容器,FormItem 默认无 margin-bottom) */
|
||||
:deep(.ivu-row) {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
:deep(.ivu-row:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.secret-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 图标上传区域 */
|
||||
.icon-upload-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px dashed #dcdee2;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #577afb;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-preview-wrap {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cloase-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-radius: 0 0 0 6px;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(220, 50, 50, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 客户端多选样式 */
|
||||
.client-select {
|
||||
width: 100%;
|
||||
|
||||
:deep(.ivu-select-selection) {
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div class="page-wrapper tree-wrapper">
|
||||
<div class="tree-container app-type-tree">
|
||||
<div class="tree-scroll">
|
||||
<ul class="grade-ul">
|
||||
<li :class="dataParams.categoryId == null ? 'active' : ''" @click="onCategoryClick(null)">
|
||||
全部
|
||||
</li>
|
||||
<li v-for="item in appTypeList" :key="item.id"
|
||||
:class="item.id == dataParams.categoryId ? 'active' : ''" @click="onCategoryClick(item.id)">
|
||||
{{ item.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tree-footer">
|
||||
<Button type="text" @click="goAppType">管理应用类型</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Button class="fl" type="primary" @click="handleAdd">新增应用</Button>
|
||||
<Button class="fl ml-8" type="default" v-if="dataParams.categoryId != null"
|
||||
@click="sortModalVisible = true">排序管理</Button>
|
||||
<Input class="fr" v-model="keyword" placeholder="输入应用名称或appKey筛选" search clearable suffix="ios-search"
|
||||
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" :tableHeight="tableHeight" />
|
||||
</div>
|
||||
|
||||
<!-- 容器组件:新增 / 编辑 -->
|
||||
<AddAppContainer ref="addRef" />
|
||||
<EditAppContainer ref="editRef" />
|
||||
|
||||
<!-- 排序管理 -->
|
||||
<AppSort v-model="sortModalVisible" :categoryId="dataParams.categoryId" :categoryName="currentCategoryName"
|
||||
@saved="onSortSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
defineOptions({ name: "AppList" });
|
||||
import { useRouter } from "vue-router";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import AddAppContainer from "./AddAppContainer.vue";
|
||||
import EditAppContainer from "./EditAppContainer.vue";
|
||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||
import { OperatorEnum } from '@/types';
|
||||
import AppSort from './AppSort.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
|
||||
const sortModalVisible = ref(false);
|
||||
const keyword = ref("");
|
||||
|
||||
const dataParams = ref<{
|
||||
categoryId: number | null;
|
||||
name?: string;
|
||||
}>({
|
||||
categoryId: null,
|
||||
name: "",
|
||||
});
|
||||
|
||||
|
||||
/* ---------------- 左侧分类切换:立即刷新 ---------------- */
|
||||
function onCategoryClick(id: number | null) {
|
||||
dataParams.value.categoryId = id;
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
/* ---------------- 输入框搜索:仅在触发搜索时提交 ---------------- */
|
||||
function onSearch() {
|
||||
dataParams.value.name = keyword.value.trim() || undefined;
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
/* ---------------- 左侧应用类型 ---------------- */
|
||||
const { data: appTypeList, send: fetchTypeList } = useRequest(
|
||||
Apis.AppSettings.appTypeList({
|
||||
transform: (res: any) => {
|
||||
const list: any[] = res ?? [];
|
||||
return list.slice().sort((a, b) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||
},
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [] }
|
||||
);
|
||||
|
||||
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
|
||||
return Apis.AppSettings.appList({
|
||||
params: {
|
||||
categoryId: dataParams.value.categoryId ?? undefined,
|
||||
name: dataParams.value.name || undefined,
|
||||
pageIndex: params.pageIndex,
|
||||
pageSize: params.pageSize,
|
||||
},
|
||||
cacheFor: null,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const typeNameMap = computed(() => {
|
||||
const map: Record<number, string> = {};
|
||||
(appTypeList.value ?? []).forEach((t: any) => (map[t.id] = t.name));
|
||||
return map;
|
||||
});
|
||||
|
||||
const currentCategoryName = computed(() => {
|
||||
if (dataParams.value.categoryId == null) return "";
|
||||
return typeNameMap.value[dataParams.value.categoryId] ?? "";
|
||||
});
|
||||
|
||||
/* ---------------- 排序保存后刷新列表 ---------------- */
|
||||
function onSortSaved() {
|
||||
tableRef.value?.reloadData(OperatorEnum.sel);
|
||||
}
|
||||
|
||||
const tableHeight = ref(
|
||||
$utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true })
|
||||
);
|
||||
|
||||
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
|
||||
const columns = [
|
||||
{ type: "index", title: "序号", width: 70 },
|
||||
|
||||
{ title: "应用名称", key: "name", width: 160 },
|
||||
{ title: "描述", key: "desc", ellipsis: true, tooltip: true, minWidth: 140 },
|
||||
{
|
||||
title: "前端",
|
||||
key: "clients",
|
||||
width: 180,
|
||||
render: (h: any, { row }: any) => {
|
||||
const map: Record<number, string> = { 1: "Pc浏览器", 2: "移动App" };
|
||||
const arr: number[] = Array.isArray(row.clients) ? row.clients : [];
|
||||
if (!arr.length) return h("span", "-");
|
||||
return h("span", arr.map((v) => map[v] ?? v).join("、"));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
align: 'center',
|
||||
width: 280,
|
||||
render: (h: any, { row }: any) => {
|
||||
return [
|
||||
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
|
||||
$utils.operatingTextHandle(h, '模块管理', '', () => goModuleManage(row)),
|
||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
|
||||
];
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
|
||||
const typeColumn = {
|
||||
title: "应用类型",
|
||||
key: "categoryId",
|
||||
width: 110,
|
||||
render: (h: any, { row }: any) => h("span", typeNameMap.value[row.categoryId] ?? "-"),
|
||||
};
|
||||
|
||||
/* ---------------- 表格最终数据源(动态列) ---------------- */
|
||||
const columnsComp = computed(() => {
|
||||
const cols = [...columns];
|
||||
// 未筛选应用类型时,展示“应用类型”列
|
||||
if (!dataParams.value.categoryId) {
|
||||
const idx = cols.findIndex((c) => c.key === "name");
|
||||
cols.splice(idx + 1, 0, typeColumn);
|
||||
}
|
||||
return cols;
|
||||
});
|
||||
|
||||
/* ---------------- 新增应用 ---------------- */
|
||||
const addRef = ref();
|
||||
|
||||
function handleAdd() {
|
||||
addRef.value?.open(appTypeList.value, dataParams.value.categoryId, () => {
|
||||
tableRef.value?.reloadData(OperatorEnum.add);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 编辑应用 ---------------- */
|
||||
const editRef = ref();
|
||||
|
||||
function handleEdit(row: any) {
|
||||
editRef.value?.open(row.id, row, appTypeList.value, () => {
|
||||
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 删除应用 ---------------- */
|
||||
async function handleDelete(row: any) {
|
||||
await MyConfirm({
|
||||
msg: `确定删除应用「${row.name || row.id}」吗?`,
|
||||
info: "删除后不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } });
|
||||
Message.success('删除成功!');
|
||||
tableRef.value?.reloadData(OperatorEnum.del);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 导航 ---------------- */
|
||||
const goAppType = () => {
|
||||
router.push("/app-manage/category");
|
||||
};
|
||||
|
||||
const goModuleManage = (row: any) => {
|
||||
router.push({
|
||||
path: "/app-manage/module",
|
||||
query: { id: row.id, name: row.name },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-type-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.tree-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.tree-footer {
|
||||
flex-shrink: 0;
|
||||
padding-top: 8px;
|
||||
text-align: center;
|
||||
background: #ffffff;
|
||||
border-top: 1px solid #eeeeee;
|
||||
|
||||
.ivu-btn-text {
|
||||
color: #577afb;
|
||||
}
|
||||
}
|
||||
|
||||
.grade-ul {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
li {
|
||||
cursor: pointer;
|
||||
background-color: #fafafa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
li.active {
|
||||
color: #577afb;
|
||||
border-color: #577afb;
|
||||
}
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
cursor: pointer;
|
||||
color: #577afb;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
color: #3d5afe;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ed4014;
|
||||
|
||||
&:hover {
|
||||
color: #c51d04;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-divider {
|
||||
margin: 0 8px;
|
||||
color: #dcdee2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<Modal v-model="modalVisible" :title="'应用排序 - ' + categoryName" width="560" :mask-closable="false"
|
||||
@on-visible-change="onVisibleChange">
|
||||
<Alert show-icon>拖动左侧手柄进行排序,排序结果会实时记录</Alert>
|
||||
|
||||
<div class="sort-loading" v-if="loading">
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<draggable v-else class="sort-list" v-model="sortList" item-key="id" handle=".drag-handle" @change="onDragChange">
|
||||
<template #item="{ element, index }">
|
||||
<div class="sort-item">
|
||||
<span class="drag-handle">⠿</span>
|
||||
<span class="sort-index">{{ index + 1 }}</span>
|
||||
<span class="sort-name">{{ element.name || element.id }}</span>
|
||||
<span class="sort-no">排序号: {{ element.sortNo ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="sort-empty" v-if="!loading && sortList.length === 0">
|
||||
暂无应用数据
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="saving" @click="handleSave" :disabled="sortList.length === 0">
|
||||
保存排序
|
||||
</Button>
|
||||
<Button @click="modalVisible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
categoryId: number | null;
|
||||
categoryName: string;
|
||||
}>(),
|
||||
{
|
||||
categoryName: "",
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: boolean): void;
|
||||
(e: "saved"): void;
|
||||
}>();
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const sortList = ref<{ id: number; name?: string; sortNo?: number }[]>([]);
|
||||
const hasChanged = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
modalVisible.value = val;
|
||||
if (val) {
|
||||
fetchSortList();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(modalVisible, (val) => {
|
||||
emit("update:modelValue", val);
|
||||
});
|
||||
|
||||
function onVisibleChange(visible: boolean) {
|
||||
if (!visible) {
|
||||
hasChanged.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSortList() {
|
||||
if (!props.categoryId) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await Apis.AppSettings.appSortList({
|
||||
params: { categoryId: props.categoryId },
|
||||
cacheFor: null,
|
||||
});
|
||||
sortList.value = (res ?? []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
|
||||
hasChanged.value = false;
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "获取排序列表失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragChange() {
|
||||
hasChanged.value = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!hasChanged.value) {
|
||||
Message.info("排序未发生变化");
|
||||
modalVisible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const ids = sortList.value.map((item) => item.id);
|
||||
await Apis.AppSettings.appSort({ data: ids });
|
||||
Message.success("排序保存成功!");
|
||||
hasChanged.value = false;
|
||||
modalVisible.value = false;
|
||||
emit("saved");
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "排序保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sort-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sort-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f4ff;
|
||||
border-color: #c0d0ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽中:当前被拖拽的元素高亮(明显橙色,不放大避免横向滚动条) */
|
||||
.sort-list :deep(.sort-item.sortable-chosen),
|
||||
.sort-list :deep(.sort-item.sortable-drag) {
|
||||
background: #fff7e6;
|
||||
border-color: #fa8c16;
|
||||
box-shadow: 0 6px 16px rgba(250, 140, 22, 0.4);
|
||||
cursor: grabbing;
|
||||
|
||||
.drag-handle {
|
||||
color: #fa8c16;
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽中:落点占位位置(虚线提示) */
|
||||
.sort-list :deep(.sort-item.sortable-ghost) {
|
||||
background: #fffbe6;
|
||||
border: 1px dashed #fa8c16;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
color: #999;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #577afb;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.sort-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-no {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.sort-loading,
|
||||
.sort-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<AppForm v-model="visible" :initForm="detailData" :categoryList="categoryList" :isEdit="true" :loading="submitting"
|
||||
@submit="handleSubmit" @cancel="visible = false" @reset-secret="handleResetSecret" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppForm from "./AppForm.vue";
|
||||
import MyConfirm from '@/components/MyConfirm'
|
||||
|
||||
/* ---------- 对外暴露 ---------- */
|
||||
const visible = ref(false);
|
||||
const categoryList = ref<any[]>([]);
|
||||
|
||||
// 编辑模式:从 appList 中根据 id 取出详情回填(无独立详情接口)
|
||||
const detailData = ref<Record<string, any>>({});
|
||||
const currentId = ref<number | null>(null);
|
||||
|
||||
// 成功后回调
|
||||
let onSuccessCallback: (() => void) | null = null;
|
||||
|
||||
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
|
||||
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
|
||||
(dto) => Apis.AppSettings.editApp({ pathParams: { id: currentId.value }, data: dto }),
|
||||
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
|
||||
);
|
||||
|
||||
onSuccess(() => {
|
||||
Message.success('编辑应用成功!');
|
||||
visible.value = false;
|
||||
onSuccessCallback?.();
|
||||
});
|
||||
onError((event) => {
|
||||
Message.error(event?.error?.message || '编辑失败');
|
||||
});
|
||||
|
||||
/* ---------- 打开编辑弹窗 ---------- */
|
||||
/**
|
||||
* @param id - 应用 ID
|
||||
* @param row - 当前行完整数据(来自表格列表),直接用作回填数据
|
||||
* @param categories - 应用类型列表
|
||||
* @param onSuccess - 编辑成功后的回调
|
||||
*/
|
||||
function open(id: number, row: any, categories: any[], onSuccess?: () => void) {
|
||||
currentId.value = id;
|
||||
categoryList.value = categories || [];
|
||||
// 直接用行数据回填(appList 接口返回的字段与表单一致)
|
||||
detailData.value = { ...row };
|
||||
onSuccessCallback = onSuccess || null;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
|
||||
async function handleSubmit(formData: Record<string, any>) {
|
||||
if (!currentId.value) return;
|
||||
form.value = {
|
||||
id: currentId.value,
|
||||
name: formData.name,
|
||||
categoryId: formData.categoryId,
|
||||
desc: formData.desc,
|
||||
clients: formData.clients,
|
||||
pcUrl: formData.pcUrl,
|
||||
pcIcon: formData.pcIcon,
|
||||
appUrl: formData.appUrl,
|
||||
appIcon: formData.appIcon,
|
||||
officialAccountUrl: formData.officialAccountUrl,
|
||||
dingtalkUrl: formData.dingtalkUrl,
|
||||
};
|
||||
await sendForm();
|
||||
}
|
||||
|
||||
/* ---------- 重置密钥(非表单提交,直接 MyConfirm + await) ---------- */
|
||||
async function handleResetSecret() {
|
||||
if (!currentId.value) return;
|
||||
try {
|
||||
await MyConfirm({
|
||||
msg: '确定要重置该应用的 Secret 吗?',
|
||||
info: '重置后原 Secret 将立即失效!',
|
||||
async onOk() {
|
||||
const newSecret = await Apis.AppSettings.resetAppSecret({
|
||||
pathParams: { id: currentId.value! },
|
||||
});
|
||||
Message.success('密钥已重置!新 Secret: ' + (newSecret || ''));
|
||||
// 更新表单中的 secret 回显值
|
||||
if (newSecret) {
|
||||
detailData.value = { ...detailData.value, appSecret: newSecret };
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open, visible });
|
||||
</script>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div class="main-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap">
|
||||
<Button class="fl" type="primary" @click="handleAdd">新增模块</Button>
|
||||
<Button class="fr" icon="ios-arrow-back" @click="goBack">返回</Button>
|
||||
</div>
|
||||
<h4 class="mt-10 second-title">{{ appName }} - 模块管理</h4>
|
||||
<Table class="module-table" :columns="columns" :data="flatData" border>
|
||||
<template #name="{ row }">
|
||||
<div class="module-name-cell" :style="{ paddingLeft: row._depth * 28 + 'px' }">
|
||||
<SvgIcon v-if="row._depth > 0" name="tree-line" :size="16" color="#999" />
|
||||
<Icon type="ios-cube-outline" size="16" />
|
||||
<span class="module-name-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑弹窗 -->
|
||||
<Modal v-model="editModal" :mask-closable="false" width="600" :title="modalTitle">
|
||||
<Form ref="formData" class="mt-8 mr-4 mb-12" :model="form" :rules="ruleValidate" :label-width="110">
|
||||
<FormItem v-if="form.parentId != null" label="父模块名称:">
|
||||
<Input :model-value="parentNameForDisplay" disabled />
|
||||
</FormItem>
|
||||
<FormItem label="模块名称:" prop="name">
|
||||
<Input placeholder="请输入模块名称" v-model="form.name" />
|
||||
</FormItem>
|
||||
<FormItem label="模块路径:" prop="path">
|
||||
<Input placeholder="请输入模块路径" v-model="form.path" />
|
||||
</FormItem>
|
||||
<FormItem label="自定义ID:" prop="customId">
|
||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入自定义ID" v-model="form.customId" />
|
||||
</FormItem>
|
||||
<FormItem label="排序号:" prop="sortNo">
|
||||
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="submitting" @click="onSave">提交</Button>
|
||||
<Button @click="onReset">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { debounce } from "lodash";
|
||||
import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import MyConfirm from "@/components/MyConfirm";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
const { $utils } = useCurrentInstance();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const appId = computed(() => Number(route.query.id));
|
||||
const appName = computed(() => (route.query.name as string) || '');
|
||||
|
||||
|
||||
/* ---------------- 数据加载(无分页) ---------------- */
|
||||
const { data: moduleList, send: fetchModuleList } = useRequest(
|
||||
Apis.AppSettings.moduleList({
|
||||
params: { appId: appId.value },
|
||||
cacheFor: null,
|
||||
}),
|
||||
{ initialData: [], immediate: true }
|
||||
);
|
||||
|
||||
/* ---------------- 递归扁平化(伪树形,支持任意层级) ----------------
|
||||
* 注意:必须剥掉 children,否则 view-ui-plus Table 会把行当成树节点处理,
|
||||
* 触发内部树形展开逻辑并读到 null 子节点,报 `_isChecked` of null 错误。 */
|
||||
function flattenTree(list: any[], depth = 0): any[] {
|
||||
const result: any[] = [];
|
||||
(list ?? []).forEach((node: any) => {
|
||||
const { children, ...rest } = node;
|
||||
const hasChildren = Array.isArray(children) && children.length > 0;
|
||||
result.push({ ...rest, _depth: depth, _isParent: node.parentId == null, _hasChildren: hasChildren });
|
||||
if (hasChildren) {
|
||||
result.push(...flattenTree(children, depth + 1));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const flatData = computed(() => flattenTree(moduleList.value));
|
||||
|
||||
/* ---------------- 表格列 ---------------- */
|
||||
const columns = [
|
||||
{ title: "模块名称", key: "name", minWidth: 260, slot: "name" },
|
||||
{ title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") },
|
||||
{ title: "路径", key: "path", minWidth: 200, render: (h: any, { row }: any) => h("span", row.path || "-") },
|
||||
{ title: "排序号", key: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") },
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 220,
|
||||
align: "center",
|
||||
render: (h: any, { row }: any) => {
|
||||
const actions: any[] = [
|
||||
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
|
||||
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
|
||||
];
|
||||
if (row.level < 5) {
|
||||
actions.splice(1, 0, $utils.operatingTextHandle(h, '添加子模块', '', () => handleAddChild(row)));
|
||||
}
|
||||
return h("div", { style: { display: "flex", justifyContent: "center", gap: "4px" } }, actions);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------- 弹窗状态 ---------------- */
|
||||
const refFormData = useTemplateRef('formData');
|
||||
const editModal = ref(false);
|
||||
const currentId = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const parentNameForDisplay = ref('');
|
||||
|
||||
const form = ref<{
|
||||
id: number | null;
|
||||
name: string;
|
||||
path: string;
|
||||
customId: number | null;
|
||||
parentId: number | null;
|
||||
sortNo: number | null;
|
||||
}>({
|
||||
id: null,
|
||||
name: '',
|
||||
path: '',
|
||||
customId: null,
|
||||
parentId: null,
|
||||
sortNo: null,
|
||||
});
|
||||
|
||||
const ruleValidate = ref({
|
||||
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
|
||||
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
|
||||
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
|
||||
});
|
||||
|
||||
const openingIsChild = ref(false);
|
||||
const modalTitle = computed(() => {
|
||||
if (currentId.value != null) return '编辑模块';
|
||||
return openingIsChild.value ? '新增子模块' : '新增模块';
|
||||
});
|
||||
|
||||
/* ---------------- 返回应用列表(keepAlive 记忆筛选) ---------------- */
|
||||
function goBack() {
|
||||
router.push("/app-manage/list");
|
||||
}
|
||||
|
||||
/* ---------------- 由嵌套 moduleList 递归构建 id->name 映射(兜底后端未返回 parentName) ---------------- */
|
||||
function buildIdNameMap(list: any[]): Record<number, string> {
|
||||
const map: Record<number, string> = {};
|
||||
(list ?? []).forEach((n: any) => {
|
||||
if (n.id != null) map[n.id] = n.name;
|
||||
if (Array.isArray(n.children) && n.children.length) Object.assign(map, buildIdNameMap(n.children));
|
||||
});
|
||||
return map;
|
||||
}
|
||||
const parentNameMap = computed(() => buildIdNameMap(moduleList.value));
|
||||
|
||||
/* ---------------- 弹窗赋值统一入口(先打开弹窗 → nextTick 内 resetFields 清空校验 → 再写入值) ----------------
|
||||
* 注意:view-ui-plus 的 resetFields() 会把所有字段重置回 mount 初值(parentId 会变成 null),
|
||||
* 因此必须先 resetFields,再赋值,否则值被清掉:
|
||||
* - 「父模块名称」整行因 v-if(parentId!=null) 不成立而消失
|
||||
* - 保存时子模块的 parentId 被清空、变成顶层模块 */
|
||||
function openEditModal(item: any) {
|
||||
currentId.value = item?.id ?? null;
|
||||
editModal.value = true;
|
||||
nextTick(() => {
|
||||
refFormData.value?.resetFields();
|
||||
const f = form.value;
|
||||
f.id = item?.id ?? null;
|
||||
f.name = item?.name || '';
|
||||
f.path = item?.path || '';
|
||||
f.customId = item?.customId != null ? Number(item.customId) : null;
|
||||
f.parentId = item?.parentId ?? null;
|
||||
f.sortNo = item?.sortNo != null ? Number(item.sortNo) : null;
|
||||
parentNameForDisplay.value = f.parentId != null
|
||||
? (parentNameMap.value[f.parentId] || '')
|
||||
: '';
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 新增模块 ---------------- */
|
||||
function handleAdd() {
|
||||
openingIsChild.value = false;
|
||||
openEditModal({ id: null, name: '', path: '', customId: null, parentId: null, sortNo: null });
|
||||
}
|
||||
|
||||
/* ---------------- 新增子模块 ---------------- */
|
||||
function handleAddChild(parent: any) {
|
||||
openingIsChild.value = true;
|
||||
openEditModal({ id: null, name: '', path: '', customId: null, parentId: parent.id, sortNo: null });
|
||||
}
|
||||
|
||||
/* ---------------- 编辑 ---------------- */
|
||||
function handleEdit(item: any) {
|
||||
openingIsChild.value = item.parentId != null;
|
||||
openEditModal(item);
|
||||
}
|
||||
|
||||
/* ---------------- 删除 ---------------- */
|
||||
async function handleDelete(item: any) {
|
||||
await MyConfirm({
|
||||
msg: `确定删除模块「${item.name || item.id}」吗?`,
|
||||
info: item._hasChildren ? "该模块下存在子模块,删除后子模块也将被删除!" : "删除后不可恢复!",
|
||||
async onOk() {
|
||||
try {
|
||||
await Apis.AppSettings.deleteModule({ pathParams: { id: item.id } });
|
||||
Message.success("删除成功!");
|
||||
await fetchModuleList();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 提交(防抖) ---------------- */
|
||||
const onSave = debounce(onRawSave, 500);
|
||||
|
||||
async function onRawSave() {
|
||||
try {
|
||||
const valid = await refFormData.value?.validate();
|
||||
if (!valid) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (currentId.value == null) {
|
||||
await Apis.AppSettings.addModule({
|
||||
data: {
|
||||
appId: appId.value,
|
||||
name: form.value.name,
|
||||
path: form.value.path,
|
||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||
parentId: form.value.parentId ?? undefined,
|
||||
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await Apis.AppSettings.editModule({
|
||||
pathParams: { id: currentId.value },
|
||||
data: {
|
||||
id: currentId.value,
|
||||
appId: appId.value,
|
||||
name: form.value.name,
|
||||
path: form.value.path,
|
||||
customId: form.value.customId == null ? '' : String(form.value.customId),
|
||||
parentId: form.value.parentId ?? undefined,
|
||||
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
|
||||
},
|
||||
});
|
||||
}
|
||||
Message.success("操作成功!");
|
||||
editModal.value = false;
|
||||
await fetchModuleList();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
refFormData.value?.resetFields();
|
||||
currentId.value = null;
|
||||
editModal.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.module-table {
|
||||
:deep(.ivu-table-cell) {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.module-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.module-name-text {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user