feat: 首页仪表板自定义布局 (#201)

Co-authored-by: tk <fiyne1a@dingtalk.com>
This commit is contained in:
nsnail 2024-11-15 15:46:15 +08:00 committed by GitHub
parent 1743f4ff28
commit 2f300285aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 244 additions and 102 deletions

View File

@ -15,7 +15,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.11.20">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.12.19">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -38,7 +38,9 @@ public sealed record DynamicFilterInfo : DataAbstraction
public static implicit operator FreeSql.Internal.Model.DynamicFilterInfo(DynamicFilterInfo d)
{
var ret = d.Adapt<FreeSql.Internal.Model.DynamicFilterInfo>();
ProcessDynamicFilter(ret);
#pragma warning disable VSTHRD002
ProcessDynamicFilterAsync(ret).ConfigureAwait(false).GetAwaiter().GetResult();
#pragma warning restore VSTHRD002
return ret;
}
@ -79,35 +81,27 @@ public sealed record DynamicFilterInfo : DataAbstraction
return !condition ? this : Add(df);
}
private static void ParseDateExp(FreeSql.Internal.Model.DynamicFilterInfo d)
private static async Task ParseDateExpAsync(FreeSql.Internal.Model.DynamicFilterInfo d)
{
var values = ((JsonElement)d.Value).Deserialize<string[]>();
if (!DateTime.TryParse(values[0], CultureInfo.InvariantCulture, out _)) {
var result = values[0]
.ExecuteCSharpCodeAsync<DateTime>([typeof(DateTime).Assembly], nameof(System))
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
var result = await values[0].ExecuteCSharpCodeAsync<DateTime>([typeof(DateTime).Assembly], nameof(System)).ConfigureAwait(false);
values[0] = $"{result:yyyy-MM-dd HH:mm:ss}";
}
if (!DateTime.TryParse(values[1], CultureInfo.InvariantCulture, out _)) {
var result = values[1]
.ExecuteCSharpCodeAsync<DateTime>([typeof(DateTime).Assembly], nameof(System))
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
var result = await values[1].ExecuteCSharpCodeAsync<DateTime>([typeof(DateTime).Assembly], nameof(System)).ConfigureAwait(false);
values[1] = $"{result:yyyy-MM-dd HH:mm:ss}";
}
d.Value = values;
}
private static void ProcessDynamicFilter(FreeSql.Internal.Model.DynamicFilterInfo d)
private static async Task ProcessDynamicFilterAsync(FreeSql.Internal.Model.DynamicFilterInfo d)
{
if (d?.Filters != null) {
foreach (var filterInfo in d.Filters) {
ProcessDynamicFilter(filterInfo);
await ProcessDynamicFilterAsync(filterInfo).ConfigureAwait(false);
}
}
@ -119,7 +113,7 @@ public sealed record DynamicFilterInfo : DataAbstraction
}
}
else if (d?.Operator == DynamicFilterOperator.DateRange) {
ParseDateExp(d);
await ParseDateExpAsync(d).ConfigureAwait(false);
}
}
}

View File

@ -3,9 +3,9 @@
<Import Project="$(SolutionDir)/build/copy.pkg.xml.comment.files.targets"/>
<Import Project="$(SolutionDir)/build/prebuild.targets"/>
<ItemGroup>
<PackageReference Include="FreeSql.DbContext.NS" Version="3.2.833-ns5" Label="refs"/>
<PackageReference Include="FreeSql.Provider.Sqlite.NS" Version="3.2.833-ns5" Label="refs"/>
<PackageReference Include="Gurion" Version="1.2.1" Label="refs"/>
<PackageReference Include="FreeSql.DbContext.NS" Version="3.2.833-ns6" Label="refs"/>
<PackageReference Include="FreeSql.Provider.Sqlite.NS" Version="3.2.833-ns6" Label="refs"/>
<PackageReference Include="Gurion" Version="1.2.2" Label="refs"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.12.0-3.final"/>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.0"/>
<PackageReference Include="Minio" Version="6.0.3"/>

View File

@ -16,7 +16,9 @@ public sealed class ApiIdAttribute : ValidationAttribute
var req = new QueryReq<QueryApiReq> { Filter = new QueryApiReq { Id = value as string } };
var method = service.GetType().GetMethod("ExistAsync");
var exist = ((Task<bool>)method!.Invoke(service, [req]))!.ConfigureAwait(false).GetAwaiter().GetResult();
#pragma warning disable VSTHRD002
var exist = ((Task<bool>)method!.Invoke(service, [req]))!.ConfigureAwait(false).GetAwaiter().GetResult();
#pragma warning restore VSTHRD002
return !exist ? new ValidationResult(Ln.) : ValidationResult.Success;
}
}

View File

@ -16,7 +16,9 @@ public sealed class UserIdAttribute : ValidationAttribute
var req = new QueryReq<QueryUserReq> { Filter = new QueryUserReq { Id = (long)value! } };
var method = service.GetType().GetMethod("ExistAsync");
var exist = ((Task<bool>)method!.Invoke(service, [req]))!.ConfigureAwait(false).GetAwaiter().GetResult();
#pragma warning disable VSTHRD002
var exist = ((Task<bool>)method!.Invoke(service, [req]))!.ConfigureAwait(false).GetAwaiter().GetResult();
#pragma warning restore VSTHRD002
return !exist ? new ValidationResult(Ln.) : ValidationResult.Success;
}
}

View File

@ -1,7 +1,7 @@
<template>
<div v-if="loading" v-loading="true" style="height: 100%"></div>
<el-main v-else>
<widgets v-if="dashboard"></widgets>
<el-main v-else :style="{ height: mainHeight }">
<widgets v-if="dashboard" @on-customizing="onCustomizing"></widgets>
<work v-else></work>
</el-main>
</template>
@ -20,6 +20,7 @@ export default {
data() {
return {
loading: true,
mainHeight: 'auto',
dashboard: false,
}
},
@ -30,7 +31,11 @@ export default {
this.loading = false
},
mounted() {},
methods: {},
methods: {
onCustomizing(isCustomizing) {
this.mainHeight = isCustomizing ? '100%' : 'auto'
},
},
}
</script>

View File

@ -0,0 +1,97 @@
<template>
<div :class="[{ active: active }]" @click="$emit('onSetLayout', realLayout)" class="selectLayout-item">
<el-row v-for="l in layout.split('-')" :gutter="2">
<el-col v-for="span in l.split(',')" :span="Number(span) > 24 ? 24 : Number(span)"><span></span></el-col>
</el-row>
</div>
</template>
<script>
export default {
components: {},
props: ['active', 'layout'],
data() {
return {}
},
computed: {
realLayout() {
return this.layout
.replaceAll('-', ',')
.split(',')
.map((x) => Number(x))
},
},
async created() {},
mounted() {},
}
</script>
<style lang="scss" scoped>
.selectLayout-item {
width: 5rem;
height: 5rem;
border: 0.2rem solid var(--el-border-color-light);
padding: 0.4rem;
cursor: pointer;
margin-right: 1rem;
display: flex;
flex-direction: column;
}
.selectLayout-item .el-row {
height: 3.6rem;
margin-bottom: 0.2rem;
}
.selectLayout-item span {
display: block;
background: var(--el-border-color-light);
}
.selectLayout-item:has(:only-child) span {
height: 100%;
}
.selectLayout-item:has(:nth-child(2)):not(:has(:nth-child(3))) > span {
height: 50%;
}
.selectLayout-item:has(:nth-child(3)):not(:has(:nth-child(4))) > span {
height: 33.3%;
}
.selectLayout-item:has(:nth-child(4)):not(:has(:nth-child(5))) > span {
height: 25%;
}
.selectLayout-item:has(:nth-child(5)):not(:has(:nth-child(6))) > span {
height: 20%;
}
.selectLayout-item:has(:nth-child(6)):not(:has(:nth-child(7))) > span {
height: 16.7%;
}
.selectLayout-item:has(:nth-child(7)):not(:has(:nth-child(8))) > span {
height: 14.3%;
}
.selectLayout-item:has(:nth-child(8)):not(:has(:nth-child(9))) > span {
height: 12.5%;
}
.selectLayout-item:has(:nth-child(9)):not(:has(:nth-child(10))) > span {
height: 11.1%;
}
.selectLayout-item:hover {
border-color: var(--na-color-primary);
}
.selectLayout-item.active {
border-color: var(--na-color-primary);
}
.selectLayout-item.active span {
background: var(--na-color-primary);
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<sc-dialog v-model="visible" :title="`${title}`" @closed="$emit('closed')" destroy-on-close>
<el-form :model="form" ref="form">
<el-form-item
v-for="(row, index) in form.rows"
:label="`第${index + 1}行`"
:prop="'rows.' + index + '.value'"
:rules="{
required: true,
message: '请输入以空格分隔的24分栏布局如【24】或【12 12】或【8 8 8】',
trigger: 'blur',
}">
<el-input
v-model="form.rows[index].value"
:input="(form.rows[index].value = form.rows[index].value.replace(/[^0-9 ]/g, ''))"
placeholder="请输入以空格分隔的24分栏布局如【24】或【12 12】或【8 8 8】">
<template #append>
<el-button @click.prevent="form.rows.splice(index, 1)" icon="delete">删除</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="this.form.rows.push({ value: '' })">{{ $t('添加一行') }}</el-button>
<el-button @click="submit" type="primary">{{ $t('保存') }}</el-button>
</template>
</sc-dialog>
</template>
<script>
export default {
components: {},
data() {
return {
title: '添加自定义布局',
visible: false,
form: {
rows: [{ value: '' }],
},
}
},
emits: ['success', 'closed', 'mounted'],
methods: {
//
async open({ title }) {
this.visible = true
this.title = title
return this
},
async submit() {
const valid = await this.$refs.form.validate().catch(() => {})
if (!valid) {
return false
}
this.form.rows.forEach((r) => {
r.value = r.value
.split(' ')
.map((x) => x.trim())
.filter((x) => x !== '')
.join(',')
})
let l = this.form.rows.map((x) => x.value).join('-')
if (l !== '') {
this.$emit('onCustomLayout', l)
}
this.visible = false
},
},
created() {},
mounted() {
this.$emit('mounted')
},
}
</script>
<style scoped></style>

View File

@ -64,42 +64,21 @@
</el-header>
<el-header style="height: auto">
<div class="selectLayout">
<div :class="{ active: grid.layout.join(',') === '12,6,6' }" @click="setLayout([12, 6, 6])" class="selectLayout-item item01">
<el-row :gutter="2">
<el-col :span="12"><span></span></el-col>
<el-col :span="6"><span></span></el-col>
<el-col :span="6"><span></span></el-col>
</el-row>
</div>
<div
:class="{ active: grid.layout.join(',') === '24,12,12' }"
@click="setLayout([24, 12, 12])"
class="selectLayout-item item02">
<el-row :gutter="2">
<el-col :span="24"><span></span></el-col>
<el-col :span="12"><span></span></el-col>
<el-col :span="12"><span></span></el-col>
</el-row>
</div>
<div
:class="{ active: grid.layout.join(',') === '24,16,8' }"
@click="setLayout([24, 16, 8])"
class="selectLayout-item item02">
<el-row :gutter="2">
<el-col :span="24"><span></span></el-col>
<el-col :span="16"><span></span></el-col>
<el-col :span="8"><span></span></el-col>
</el-row>
</div>
<div :class="{ active: grid.layout.join(',') === '24' }" @click="setLayout([24])" class="selectLayout-item item03">
<el-row :gutter="2">
<el-col :span="24"><span></span></el-col>
<el-col :span="24"><span></span></el-col>
<el-col :span="24"><span></span></el-col>
</el-row>
</div>
<layout
v-for="l in layouts"
:active="grid.layout.join(',') === l.replaceAll('-', ',')"
:layout="l"
@onSetLayout="setLayout"></layout>
<layout
v-for="l in customLayouts"
:active="grid.layout.join(',') === l.replaceAll('-', ',')"
:layout="l"
@onSetLayout="setLayout"></layout>
</div>
</el-header>
<el-header style="height: auto">
<el-button @click="this.dialog.customLayout = { title: '添加自定义布局' }" style="margin: 0 auto">添加自定义布局</el-button>
</el-header>
<el-main class="nopadding">
<div class="widgets-list">
<div v-if="myCompsList.length <= 0" class="widgets-list-nodata">
@ -129,17 +108,30 @@
</div>
<div @click="custom" class="layout-setting">
<el-icon><el-icon-setting /></el-icon>
<el-icon>
<el-icon-setting />
</el-icon>
</div>
<custom-layout-dialog
v-if="dialog.customLayout"
@closed="dialog.customLayout = null"
@mounted="$refs.customLayoutDialog.open(dialog.customLayout)"
@onCustomLayout="(l) => (customLayouts = [l])"
ref="customLayoutDialog"></custom-layout-dialog>
</template>
<script>
import draggable from 'vuedraggable'
import allComps from './components'
import customLayoutDialog from './dialog/custom-layout-dialog.vue'
import layout from './components/components/layout.vue'
export default {
components: {
draggable,
customLayoutDialog,
layout,
},
data() {
return {
@ -148,6 +140,9 @@ export default {
selectLayout: [],
defaultGrid: this.$CONFIG.APP_SET_HOME_GRID,
grid: [],
layouts: ['12,6,6', '24-12,12', '24-16,8', '24-24-24'],
customLayouts: [],
dialog: {},
}
},
created() {
@ -197,16 +192,23 @@ export default {
methods: {
//
custom() {
this.customizing = true
this.customizing = !this.customizing
const oldWidth = this.$refs.widgets.offsetWidth
this.$nextTick(() => {
const scale = this.$refs.widgets.offsetWidth / oldWidth
this.$refs.widgets.style.setProperty('transform', `scale(${scale})`)
this.$refs.widgets.style.setProperty('transform', `scale(${this.customizing ? scale : 1})`)
})
this.$emit('on-customizing', this.customizing)
},
//
setLayout(layout) {
this.grid.layout = layout
//
layout.forEach((_, i) => {
if (!this.grid.compsList[i]) this.grid.compsList[i] = []
})
if (layout.join(',') === '24') {
this.grid.compsList[0] = [...this.grid.compsList[0], ...this.grid.compsList[1], ...this.grid.compsList[2]]
this.grid.compsList[1] = []
@ -230,6 +232,7 @@ export default {
this.customizing = false
this.$refs.widgets.style.removeProperty('transform')
this.$TOOL.data.set('APP_SET_HOME_GRID', this.grid)
this.$emit('on-customizing', this.customizing)
},
//
backDefault() {
@ -237,12 +240,14 @@ export default {
this.$refs.widgets.style.removeProperty('transform')
this.grid = JSON.parse(JSON.stringify(this.defaultGrid))
this.$TOOL.data.remove('APP_SET_HOME_GRID')
this.$emit('on-customizing', this.customizing)
},
//
close() {
this.customizing = false
this.$refs.widgets.style.removeProperty('transform')
this.loadGrid()
this.$emit('on-customizing', this.customizing)
},
loadGrid() {
this.grid = this.$TOOL.data.get('APP_SET_HOME_GRID') || JSON.parse(JSON.stringify(this.defaultGrid))
@ -436,47 +441,6 @@ export default {
display: flex;
}
.selectLayout-item {
width: 5rem;
height: 5rem;
border: 0.2rem solid var(--el-border-color-light);
padding: 0.4rem;
cursor: pointer;
margin-right: 1rem;
}
.selectLayout-item span {
display: block;
background: var(--el-border-color-light);
height: 3.6rem;
}
.selectLayout-item.item02 span {
height: 2.4rem;
}
.selectLayout-item.item02 .el-col:nth-child(1) span {
height: 1.1rem;
margin-bottom: 0.2rem;
}
.selectLayout-item.item03 span {
height: 1.1rem;
margin-bottom: 0.2rem;
}
.selectLayout-item:hover {
border-color: var(--na-color-primary);
}
.selectLayout-item.active {
border-color: var(--na-color-primary);
}
.selectLayout-item.active span {
background: var(--na-color-primary);
}
.dark {
.widgets-aside {
background: #2b2b2b;