init: UserBaseFront (frontend)

This commit is contained in:
chenxing
2026-08-10 14:41:13 +08:00
commit eea881b616
174 changed files with 32833 additions and 0 deletions
+335
View File
@@ -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>