mirror of
https://github.com/fastapi-admin/fastapi-admin.git
synced 2026-03-13 10:32:25 +08:00
first commit
This commit is contained in:
263
front/src/components/AvatarCropper.vue
Normal file
263
front/src/components/AvatarCropper.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="avatar-cropper">
|
||||
<div v-if="dataUrl" class="avatar-cropper-overlay">
|
||||
<div class="avatar-cropper-mark"><a href="javascript:;" class="avatar-cropper-close" @click="destroy">×</a></div>
|
||||
<div class="avatar-cropper-container">
|
||||
<div class="avatar-cropper-image-container">
|
||||
<img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" />
|
||||
</div>
|
||||
<div class="avatar-cropper-footer">
|
||||
<button class="avatar-cropper-btn" type="button" @click="destroy" v-text="labels.cancel">Cancel</button>
|
||||
<button class="avatar-cropper-btn" type="button" @click="submit" v-text="labels.submit">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="input" type="file" class="avatar-cropper-img-input" :accept="mimes">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
import Cropper from 'cropperjs'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
trigger: {
|
||||
type: [String, Element],
|
||||
required: true
|
||||
},
|
||||
uploadHandler: {
|
||||
type: Function,
|
||||
},
|
||||
uploadUrl: {
|
||||
type: String,
|
||||
},
|
||||
uploadHeaders: {
|
||||
type: Object,
|
||||
},
|
||||
uploadFormName: {
|
||||
type: String,
|
||||
default: 'file'
|
||||
},
|
||||
uploadFormData: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
cropperOptions: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
aspectRatio: 1,
|
||||
autoCropArea: 1,
|
||||
viewMode: 1,
|
||||
movable: false,
|
||||
zoomable: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
outputOptions: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
width: 512,
|
||||
height: 512
|
||||
}
|
||||
}
|
||||
},
|
||||
outputMime: {
|
||||
type: String,
|
||||
default: 'image/jpeg'
|
||||
},
|
||||
outputQuality: {
|
||||
type: Number,
|
||||
default: 0.9
|
||||
},
|
||||
mimes: {
|
||||
type: String,
|
||||
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
|
||||
},
|
||||
labels: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
submit: "提交",
|
||||
cancel: "取消"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
cropper: undefined,
|
||||
dataUrl: undefined,
|
||||
filename: undefined
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
destroy() {
|
||||
this.cropper.destroy()
|
||||
this.$refs.input.value = '';
|
||||
this.dataUrl = undefined
|
||||
},
|
||||
submit() {
|
||||
this.$emit('submit')
|
||||
if (this.uploadUrl) {
|
||||
this.uploadImage()
|
||||
} else if (this.uploadHandler) {
|
||||
this.uploadHandler(this.cropper)
|
||||
} else {
|
||||
this.$emit('error', 'No upload handler found.', 'user')
|
||||
}
|
||||
this.destroy()
|
||||
},
|
||||
pickImage() {
|
||||
this.$refs.input.click()
|
||||
},
|
||||
createCropper() {
|
||||
this.cropper = new Cropper(this.$refs.img, this.cropperOptions)
|
||||
},
|
||||
uploadImage() {
|
||||
this.cropper.getCroppedCanvas(this.outputOptions).toBlob((blob) => {
|
||||
let form = new FormData()
|
||||
let xhr = new XMLHttpRequest()
|
||||
let data = Object.assign({}, this.uploadFormData)
|
||||
|
||||
for (let key in data) {
|
||||
form.append(key, data[key])
|
||||
}
|
||||
|
||||
form.append(this.uploadFormName, blob, this.filename)
|
||||
|
||||
this.$emit('uploading', form, xhr)
|
||||
|
||||
xhr.open('POST', this.uploadUrl, true)
|
||||
|
||||
for (let header in this.uploadHeaders) {
|
||||
xhr.setRequestHeader(header, this.uploadHeaders[header])
|
||||
}
|
||||
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
let response = ''
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText)
|
||||
} catch (err) {
|
||||
response = xhr.responseText
|
||||
}
|
||||
this.$emit('completed', response, form, xhr)
|
||||
|
||||
if ([200, 201, 204].indexOf(xhr.status) > -1) {
|
||||
this.$emit('uploaded', response, form, xhr)
|
||||
} else {
|
||||
this.$emit('error', 'Image upload fail.', 'upload', xhr)
|
||||
}
|
||||
}
|
||||
}
|
||||
xhr.send(form);
|
||||
}, this.outputMime, this.outputQuality)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// listen for click event on trigger
|
||||
let trigger = typeof this.trigger == 'object' ? this.trigger : document.querySelector(this.trigger)
|
||||
if (!trigger) {
|
||||
this.$emit('error', 'No avatar make trigger found.', 'user')
|
||||
} else {
|
||||
trigger.addEventListener('click', this.pickImage)
|
||||
}
|
||||
|
||||
// listen for input file changes
|
||||
let fileInput = this.$refs.input
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files != null && fileInput.files[0] != null) {
|
||||
let reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
this.dataUrl = e.target.result
|
||||
}
|
||||
|
||||
reader.readAsDataURL(fileInput.files[0])
|
||||
|
||||
this.filename = fileInput.files[0].name || 'unknown'
|
||||
this.$emit('changed', fileInput.files[0], reader)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.avatar-cropper {
|
||||
.avatar-cropper-overlay {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.avatar-cropper-img-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.avatar-cropper-close {
|
||||
float: right;
|
||||
padding: 20px;
|
||||
font-size: 3rem;
|
||||
color: #fff;
|
||||
font-weight: 100;
|
||||
text-shadow: 0px 1px rgba(40, 40, 40,.3);
|
||||
}
|
||||
|
||||
.avatar-cropper-mark {
|
||||
position: fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
background: rgba(0, 0, 0, .10);
|
||||
}
|
||||
|
||||
.avatar-cropper-container {
|
||||
background: #fff;
|
||||
z-index: 999;
|
||||
box-shadow: 1px 1px 5px rgba(100, 100, 100, .14);
|
||||
|
||||
.avatar-cropper-image-container {
|
||||
position: relative;
|
||||
max-width: 400px;
|
||||
height: 300px;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-cropper-footer {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-content: stretch;
|
||||
justify-content: space-between;
|
||||
|
||||
.avatar-cropper-btn {
|
||||
width: 50%;
|
||||
padding: 15px 0;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
&:hover {
|
||||
background-color: #2aabd2;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
front/src/components/BDataTable.vue
Normal file
14
front/src/components/BDataTable.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<b-table>
|
||||
|
||||
</b-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data(){
|
||||
return {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
70
front/src/components/CustomComponent.vue
Normal file
70
front/src/components/CustomComponent.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div>
|
||||
<component :is="component" v-if="component"></component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
config: {
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
component: null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
},
|
||||
watch: {
|
||||
config: 'setComponent'
|
||||
},
|
||||
methods: {
|
||||
setComponent() {
|
||||
if (!this.config) {
|
||||
return this.component = null;
|
||||
}
|
||||
if (typeof this.config === 'string') {
|
||||
return this.component = {
|
||||
template: this.config
|
||||
}
|
||||
}
|
||||
const component = {};
|
||||
const config = this.config;
|
||||
const rawData = Object.assign({}, config.data);
|
||||
component.data = rawData;
|
||||
component.name = config.name
|
||||
if (typeof component.data !== "function") {
|
||||
component.data = function() {
|
||||
return Object.assign({}, rawData);
|
||||
};
|
||||
}
|
||||
const wrapFunction = v => {
|
||||
if (Array.isArray(v)) {
|
||||
return new Function(...v);
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const mapValues = (obj = {}) => {
|
||||
return Object.entries(obj).reduce((acc, [k, v]) => {
|
||||
acc[k] = wrapFunction(v);
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
component.methods = mapValues(config.methods, wrapFunction);
|
||||
component.computed = mapValues(config.computed, wrapFunction);
|
||||
component.created = config.created ? wrapFunction(config.created) : null;
|
||||
component.mounted = config.mounted ? wrapFunction(config.mounted) : null;
|
||||
component.watch = config.watch ? wrapFunction(config.watch) : null;
|
||||
component.template = config.template
|
||||
component.render = wrapFunction(config.render)
|
||||
this.component = Object.assign({}, component, {});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
43
front/src/components/DataChart.vue
Normal file
43
front/src/components/DataChart.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script>
|
||||
// import Chart from "vue-chartjs";
|
||||
|
||||
export default {
|
||||
// extends: Chart.Pie,
|
||||
props: {
|
||||
resource: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
group: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
options: {}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
resourceUri() {
|
||||
return this.resource + "/stat";
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetch() {
|
||||
this.$http.get(this.resourceUri, {
|
||||
params: {group: this.group}
|
||||
}).then(({ data }) => {
|
||||
this.options = data;
|
||||
this.renderChart(this.options)
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Overwriting base render method with actual data.
|
||||
this.fetch()
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
165
front/src/components/DataForm.vue
Normal file
165
front/src/components/DataForm.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<!-- <component :is="tag" :header="header"> -->
|
||||
<div class="data-form">
|
||||
<div class="row d-none">
|
||||
<div class="col col-md-8">
|
||||
<legend
|
||||
v-if="model[$config.primaryKey] && false"
|
||||
>{{$t('actions.edit')}}: {{model[$config.primaryKey]}}</legend>
|
||||
</div>
|
||||
<div class="col col-md-4 text-right hidden-sm-down">
|
||||
<b-btn @click="$router.go(-1)">{{$t('actions.back')}}</b-btn>
|
||||
<b-btn variant="primary" @click="$refs.form.submitForm()">{{$t('actions.save')}}</b-btn>
|
||||
</div>
|
||||
</div>
|
||||
<b-form-builder
|
||||
:languages="$store.state.site.languages"
|
||||
group-by="group"
|
||||
v-if="loaded"
|
||||
:auth="auth"
|
||||
:layout="layout"
|
||||
:fields="fields"
|
||||
ref="form"
|
||||
v-model="model"
|
||||
:action="resourceUri"
|
||||
:method="method"
|
||||
@success="onSuccess"
|
||||
></b-form-builder>
|
||||
</div>
|
||||
<!-- </component> -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState, mapGetters } from "vuex";
|
||||
import _ from "lodash";
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
resource: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
id: {
|
||||
default: "",
|
||||
required: true
|
||||
},
|
||||
formPath: {
|
||||
type: String,
|
||||
default: "form",
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
redirect: -1,
|
||||
loaded: false,
|
||||
choices: {},
|
||||
fields: {},
|
||||
model: {},
|
||||
errors: [],
|
||||
tag: "b-card",
|
||||
header: `
|
||||
${_.get(this.currentMenu, "name", "") || ""}
|
||||
<small> ${this.resource.toUpperCase()} </small>
|
||||
`
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
id: "fetchForm",
|
||||
"site.fetched"(val){
|
||||
if (val) {
|
||||
this.fetchForm(true)
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
resourceUri() {
|
||||
let url = [this.site.resource_prefix, this.resource, this.id]
|
||||
.filter(v => v)
|
||||
.join("/");
|
||||
let group = this.$route.params.group;
|
||||
if (group) {
|
||||
url += "?group=" + group;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
formUri() {
|
||||
let url = [this.site.resource_prefix, this.resource, this.formPath]
|
||||
.filter(v => v)
|
||||
.join("/");
|
||||
url += "?id=" + (this.id || "");
|
||||
return url;
|
||||
},
|
||||
isNew() {
|
||||
return !this.id;
|
||||
},
|
||||
method() {
|
||||
return this.isNew ? "post" : "put";
|
||||
},
|
||||
with() {
|
||||
return _.filter(
|
||||
_.map(this.fields, v => v.ref && v.ref.split(".").shift())
|
||||
);
|
||||
},
|
||||
...mapState(["nav", "auth", "site"]),
|
||||
...mapGetters(["currentMenu"])
|
||||
},
|
||||
methods: {
|
||||
fetch() {
|
||||
if (this.isNew) {
|
||||
this.model = {};
|
||||
this.loaded = true;
|
||||
return;
|
||||
}
|
||||
this.$http
|
||||
.get(this.resourceUri, {
|
||||
params: {
|
||||
query: {
|
||||
with: this.with
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(({ data }) => {
|
||||
this.loaded = true;
|
||||
this.model = data;
|
||||
});
|
||||
},
|
||||
fetchForm() {
|
||||
this.$http
|
||||
.get(this.formUri, {
|
||||
params: this.$route.params
|
||||
})
|
||||
.then(({ data }) => {
|
||||
this.fields = data.fields;
|
||||
this.layout = data.layout;
|
||||
this.redirect = data.redirect;
|
||||
if (data.header) {
|
||||
this.header = data.header;
|
||||
}
|
||||
if (data.tag) {
|
||||
this.tag = data.tag;
|
||||
}
|
||||
this.fetch();
|
||||
});
|
||||
},
|
||||
|
||||
onSuccess() {
|
||||
if (this.redirect === false) {
|
||||
this.fetchForm();
|
||||
} else if (this.redirect === -1 || !this.redirect) {
|
||||
this.$router.go(-1);
|
||||
} else {
|
||||
this.$router.go(this.redirect);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.site.fetched && this.fetchForm()
|
||||
},
|
||||
created() {
|
||||
// this.fetchForm();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
541
front/src/components/DataTable.vue
Normal file
541
front/src/components/DataTable.vue
Normal file
@@ -0,0 +1,541 @@
|
||||
<template>
|
||||
<div class="data-table">
|
||||
<div v-if="site.grid_style === 1">
|
||||
<div class="pb-3">
|
||||
<b-btn
|
||||
class="mr-2"
|
||||
:to="'/rest/' + resourceUri + '/create'"
|
||||
variant="secondary"
|
||||
v-if="_.get(actions,'toolbar.create') !== false"
|
||||
>
|
||||
<i class="icon-plus"></i>
|
||||
{{$t('actions.create')}}
|
||||
</b-btn>
|
||||
<b-btn
|
||||
class="mr-2"
|
||||
@click="fetchGrid"
|
||||
variant="success"
|
||||
v-if="_.get(actions, 'toolbar.reload') !== false"
|
||||
>
|
||||
<i class="icon-reload"></i>
|
||||
{{$t('actions.reload')}}
|
||||
</b-btn>
|
||||
<b-btn
|
||||
class="mr-2"
|
||||
v-for="button in _.get(actions, 'toolbar.extra', [])"
|
||||
:key="button.label"
|
||||
v-bind="button"
|
||||
>{{button.label}}</b-btn>
|
||||
<b-btn
|
||||
@click="removeAll"
|
||||
class="pull-right"
|
||||
variant="second"
|
||||
v-if="_.get(actions, 'toolbar.delete_all') === true"
|
||||
>
|
||||
<i class="icon-trash"></i>
|
||||
{{$t('actions.delete_all')}}
|
||||
</b-btn>
|
||||
</div>
|
||||
<div class="mb-2 data-table-search" v-if="!_.isEmpty(searchFields)">
|
||||
<b-form-builder
|
||||
:inline="true"
|
||||
:fields="searchFields"
|
||||
:action="searchUri"
|
||||
v-model="searchModel"
|
||||
:submitText="$t('actions.search')"
|
||||
backText
|
||||
method="get"
|
||||
:on-submit="onSearch"
|
||||
>
|
||||
<div slot="extra-buttons" class="ml-2">
|
||||
<b-button
|
||||
type="button"
|
||||
@click="searchAndExport"
|
||||
variant="success"
|
||||
v-if="_.get(actions, 'export')"
|
||||
>{{$t('actions.search_and_export')}}</b-button>
|
||||
<iframe :src="iframeSrc" style="width:0;height:0;border:none;"></iframe>
|
||||
</div>
|
||||
</b-form-builder>
|
||||
</div>
|
||||
<div class v-if="description" v-html="description"></div>
|
||||
<b-row>
|
||||
<b-col cols="8">
|
||||
<b-pagination
|
||||
:limit="limitPages"
|
||||
:total-rows="totalRows"
|
||||
:per-page="perPage"
|
||||
v-model="page"
|
||||
/>
|
||||
</b-col>
|
||||
<b-col cols="4" class="text-right">
|
||||
<p>{{$t('messages.paginate', {total: totalRows})}}</p>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
<div v-else class="table-toolbar style-2">
|
||||
<div class="search-box">
|
||||
<div class="flex">
|
||||
<b-form-builder
|
||||
:inline="true"
|
||||
v-if="!_.isEmpty(searchFields)"
|
||||
:fields="searchFields"
|
||||
:action="searchUri"
|
||||
v-model="searchModel"
|
||||
:submitText="$t('actions.search')"
|
||||
backText
|
||||
method="get"
|
||||
:on-submit="onSearch"
|
||||
>
|
||||
<div slot="extra-buttons" class="ml-2">
|
||||
<b-button
|
||||
type="button"
|
||||
@click="searchAndExport"
|
||||
variant="success"
|
||||
v-if="_.get(actions, 'export')"
|
||||
>{{$t('actions.search_and_export')}}</b-button>
|
||||
<iframe :src="iframeSrc" style="width:0;height:0;border:none;"></iframe>
|
||||
</div>
|
||||
</b-form-builder>
|
||||
<b-btn
|
||||
:to="'/page/' + resourceUri + '.create'"
|
||||
variant="link"
|
||||
v-if="_.get(actions,'toolbar.create') !== false"
|
||||
>
|
||||
<i class="iconfont icon-xinjianshiti"></i>
|
||||
{{$t('actions.create')}}
|
||||
</b-btn>
|
||||
<b-btn
|
||||
@click="fetchGrid"
|
||||
variant="link"
|
||||
v-if="_.get(actions, 'toolbar.reload') !== false"
|
||||
>
|
||||
<i class="iconfont icon-shuaxin"></i>
|
||||
{{$t('actions.reload')}}
|
||||
</b-btn>
|
||||
</div>
|
||||
<!-- <b-pagination :limit="limitPages" :total-rows="totalRows" :per-page="perPage" v-model="page" /> -->
|
||||
<div class="flex">
|
||||
<div class="result">共{{totalRows}}个结果</div>
|
||||
<div class="pagination">
|
||||
<span class="left-arrow iconfont icon-tuichu" @click="previousPage"></span>
|
||||
<div class="go-page flex">
|
||||
<input type="number" v-model="inputPage">
|
||||
<div>/{{totalRows == 0 ? 1 : Math.ceil(totalRows/limitPages)}}</div>
|
||||
<button @click="goPage">GO</button>
|
||||
</div>
|
||||
<span class="right-arrow iconfont icon-jinru" @click="nextPage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<b-table
|
||||
class="data-table bg-white"
|
||||
:sort-by.sync="sortBy"
|
||||
:sort-desc.sync="sortDesc"
|
||||
:no-local-sorting="true"
|
||||
:fields="fields"
|
||||
:items="items"
|
||||
>
|
||||
<template
|
||||
v-for="(field, key) in _.omit(fields, '_actions')"
|
||||
:slot="`HEAD_${key}`"
|
||||
>
|
||||
<div
|
||||
:key="key"
|
||||
:class="{'text-right': ['number'].includes(field.type)}"
|
||||
>{{field.label || key}}</div>
|
||||
</template>
|
||||
<template v-for="(field, key) in fields" :slot="key" slot-scope="row">
|
||||
<b-data-value
|
||||
:field="field"
|
||||
:key="key"
|
||||
:lang="currentLanguage"
|
||||
:name="key"
|
||||
:model="row.item"
|
||||
short-id
|
||||
/>
|
||||
</template>
|
||||
<template slot="_actions" slot-scope="row">
|
||||
<b-btn
|
||||
size="sm"
|
||||
class="mr-2"
|
||||
v-for="(button, key) in _.get(actions, 'addon')"
|
||||
:key="key"
|
||||
v-bind="button"
|
||||
>{{button.label}}</b-btn>
|
||||
<b-btn
|
||||
size="sm"
|
||||
class="mr-2"
|
||||
variant="success"
|
||||
@click.stop="show(row.item)"
|
||||
v-if="_.get(actions, 'buttons.show') !== false"
|
||||
>
|
||||
<i class="icon-eye"></i>
|
||||
{{$t('actions.view')}}
|
||||
</b-btn>
|
||||
<b-btn
|
||||
size="sm"
|
||||
class="mr-2"
|
||||
variant="primary"
|
||||
@click.stop="edit(row.item)"
|
||||
v-if="_.get(actions, 'buttons.edit') !== false"
|
||||
>
|
||||
<i class="icon-pencil"></i>
|
||||
{{$t('actions.edit')}}
|
||||
</b-btn>
|
||||
<b-btn
|
||||
size="sm"
|
||||
class="mr-2"
|
||||
variant="second"
|
||||
@click.stop="remove(row.item)"
|
||||
v-if="_.get(actions, 'buttons.delete') !== false"
|
||||
>
|
||||
<i class="icon-trash"></i>
|
||||
{{$t('actions.delete')}}
|
||||
</b-btn>
|
||||
</template>
|
||||
</b-table>
|
||||
<div v-if="site.grid_style === 1">
|
||||
<b-row>
|
||||
<b-col cols="8">
|
||||
<b-pagination
|
||||
:limit="limitPages"
|
||||
:total-rows="totalRows"
|
||||
:per-page="perPage"
|
||||
v-model="page"
|
||||
/>
|
||||
</b-col>
|
||||
<b-col cols="4" class="text-right">
|
||||
<p>{{$t('messages.paginate', {total: totalRows})}}</p>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="footer-pagination">
|
||||
<div class="flex">
|
||||
<div class="result">共{{totalRows}}个结果</div>
|
||||
<div class="pagination">
|
||||
<span class="left-arrow iconfont icon-tuichu" @click="previousPage"></span>
|
||||
<div class="go-page flex">
|
||||
<input type="number" v-model="inputPage">
|
||||
<div>/{{totalRows == 0 ? 1 : Math.ceil(totalRows/limitPages)}}</div>
|
||||
<button @click="goPage">GO</button>
|
||||
</div>
|
||||
<span class="right-arrow iconfont icon-jinru" @click="nextPage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from "lodash";
|
||||
import BFormBuilder from "./FormBuilder";
|
||||
import BDataValue from "./DataValue";
|
||||
import { mapState, mapGetters } from "vuex";
|
||||
export default {
|
||||
components: {
|
||||
BFormBuilder,
|
||||
BDataValue
|
||||
},
|
||||
props: {
|
||||
resource: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
gridPath: {
|
||||
type: String,
|
||||
default: "grid",
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
iframeSrc: null,
|
||||
pause: true, //修复切换页面时page等参数的自动变更会导致多次fetch的问题
|
||||
page: 1,
|
||||
inputPage: 1,
|
||||
perPage: 6,
|
||||
sortBy: this.$config.primaryKey,
|
||||
sortDesc: true,
|
||||
description: "",
|
||||
fields: {},
|
||||
filter: {},
|
||||
choices: {},
|
||||
totalRows: 0,
|
||||
items: [],
|
||||
searchFields: {},
|
||||
searchModel: {},
|
||||
where: {},
|
||||
title: "",
|
||||
limitPages: 10
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(["site", "i18n"]),
|
||||
...mapGetters(["currentMenu", "currentLanguage"]),
|
||||
actions() {
|
||||
return _.get(this.fields, "_actions");
|
||||
},
|
||||
header() {
|
||||
return `
|
||||
${this.currentMenu.name || ""}
|
||||
<small> ${this.resource.toUpperCase()} </small>
|
||||
`;
|
||||
},
|
||||
with() {
|
||||
return _.filter(
|
||||
_.map(
|
||||
this.fields,
|
||||
(v, k) =>
|
||||
v.ref &&
|
||||
v.ref
|
||||
.split(".")
|
||||
.slice(0, -1)
|
||||
.join(".")
|
||||
)
|
||||
//.concat(_.map(this.fields, (v, k) => (v.ref ? k : null)))
|
||||
);
|
||||
},
|
||||
searchUri() {
|
||||
return this.resource;
|
||||
},
|
||||
resourceUri() {
|
||||
return [this.site.resource_prefix, this.resource]
|
||||
.map(v => v.trim("/"))
|
||||
.filter(v => v)
|
||||
.join("/");
|
||||
},
|
||||
gridUri() {
|
||||
return [this.site.resource_prefix, this.resource, this.gridPath]
|
||||
.filter(v => v)
|
||||
.join("/");
|
||||
},
|
||||
sort: {
|
||||
get() {
|
||||
if (!this.sortBy) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
[this.sortBy]: this.sortDesc ? -1 : 1
|
||||
};
|
||||
},
|
||||
set(val) {
|
||||
for (let k in val) {
|
||||
this.sortBy = k;
|
||||
this.sortDesc = val === -1;
|
||||
}
|
||||
}
|
||||
},
|
||||
query: {
|
||||
get() {
|
||||
return {
|
||||
sort: this.sort,
|
||||
page: this.page,
|
||||
perPage: this.perPage,
|
||||
with: this.with,
|
||||
where: this.where
|
||||
};
|
||||
},
|
||||
set(val) {
|
||||
this.sort = val.sort;
|
||||
this.page = val.page;
|
||||
this.perPage = val.perPage;
|
||||
this.where = val.where;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
"site.fetched"(val) {
|
||||
if (val) {
|
||||
this.fetchGrid(true);
|
||||
}
|
||||
},
|
||||
"$route.params.resource"() {
|
||||
this.inputPage = 1;
|
||||
this.pause = true;
|
||||
this.fetchGrid(true);
|
||||
},
|
||||
page: "fetch",
|
||||
sort: "fetch",
|
||||
where: "fetch",
|
||||
"$route.query.query"() {
|
||||
this.applyQuery();
|
||||
this.fetch();
|
||||
}
|
||||
// query(val) {
|
||||
// this.$emit("change query", val);
|
||||
// // this.$router.push({
|
||||
// // query: {
|
||||
// // query: JSON.stringify(val)
|
||||
// // }
|
||||
// // });
|
||||
// }
|
||||
},
|
||||
methods: {
|
||||
goPage() {
|
||||
const totalPages = Math.ceil(this.totalRows / this.limitPages);
|
||||
if (this.inputPage <= 0 || this.inputPage > totalPages) {
|
||||
this.$snotify.warning("请输入正确页码");
|
||||
this.inputPage = 1;
|
||||
this.page = 1;
|
||||
return;
|
||||
}
|
||||
this.page = this.inputPage;
|
||||
},
|
||||
previousPage() {
|
||||
// const totalPages = Math.ceil(this.totalRows/this.limitPages)
|
||||
if (this.inputPage > 1) {
|
||||
this.inputPage--;
|
||||
this.page = this.inputPage;
|
||||
} else {
|
||||
this.$snotify.warning("已是第一页");
|
||||
this.inputPage = 1;
|
||||
this.page = 1;
|
||||
}
|
||||
},
|
||||
nextPage() {
|
||||
const totalPages = Math.ceil(this.totalRows / this.limitPages);
|
||||
if (this.inputPage < totalPages) {
|
||||
this.inputPage++;
|
||||
this.page = this.inputPage;
|
||||
} else {
|
||||
this.$snotify.warning("后面没有了");
|
||||
this.inputPage = 1;
|
||||
this.page = 1;
|
||||
}
|
||||
},
|
||||
fetch() {
|
||||
if (this.pause) {
|
||||
return;
|
||||
}
|
||||
this.pause = true;
|
||||
this.$http
|
||||
.get(this.resourceUri, {
|
||||
params: {
|
||||
query: JSON.stringify(this.query)
|
||||
}
|
||||
})
|
||||
.then(({ data }) => {
|
||||
const {
|
||||
data: items,
|
||||
total,
|
||||
perPage,
|
||||
fields,
|
||||
searchFields,
|
||||
searchModel
|
||||
} = data;
|
||||
fields && (this.fields = fields);
|
||||
searchFields && (this.searchFields = searchFields);
|
||||
searchModel && (this.searchModel = searchModel);
|
||||
this.items = items;
|
||||
this.description = data.description;
|
||||
this.totalRows = total;
|
||||
this.perPage = perPage;
|
||||
this.pause = false;
|
||||
});
|
||||
},
|
||||
searchAndExport() {
|
||||
const query = JSON.stringify({
|
||||
where: _.clone(this.searchModel),
|
||||
with: _.clone(this.with)
|
||||
});
|
||||
this.iframeSrc = "";
|
||||
setTimeout(() => {
|
||||
this.iframeSrc = `${global.API_URI}${
|
||||
this.resourceUri
|
||||
}/export?query=${query}&token=${this.$store.state.auth.token}`;
|
||||
}, 50);
|
||||
},
|
||||
fetchGrid(fetchData = false) {
|
||||
// if (this.$store.state.site.use_field_apis === false) {
|
||||
// this.pause = false;
|
||||
// this.applyQuery();
|
||||
// this.fetch();
|
||||
// return
|
||||
// }
|
||||
this.query = {};
|
||||
this.$http.get(this.gridUri).then(({ data }) => {
|
||||
this.fields = data.fields;
|
||||
if (!this.fields._actions && this.fields._actions !== false) {
|
||||
this.fields._actions = {};
|
||||
}
|
||||
if (this.fields._actions) {
|
||||
if (!this.fields._actions.label) {
|
||||
this.fields._actions.label = "";
|
||||
}
|
||||
}
|
||||
this.searchFields = data.searchFields;
|
||||
this.searchModel = data.searchModel;
|
||||
this.pause = false;
|
||||
if (fetchData) {
|
||||
this.applyQuery();
|
||||
this.fetch();
|
||||
}
|
||||
});
|
||||
},
|
||||
applyQuery() {
|
||||
const query = this.$route.query.query;
|
||||
if (!query) {
|
||||
this.query = {};
|
||||
return;
|
||||
}
|
||||
this.query = _.isString(query) ? JSON.parse(query) : query;
|
||||
// this.searchModel = this.where
|
||||
},
|
||||
get(item, path) {
|
||||
const [model, field] = path.split(".");
|
||||
return item[model][field];
|
||||
},
|
||||
show(item) {
|
||||
this.$router.push({
|
||||
path: this.resource + "/" + item[this.$config.primaryKey]
|
||||
});
|
||||
},
|
||||
edit(item) {
|
||||
if (this.site.grid_style == 2) {
|
||||
return this.$router.push({
|
||||
path: `/page/${this.resource}.${item[this.$config.primaryKey]}.edit`
|
||||
});
|
||||
}
|
||||
this.$router.push({
|
||||
path: this.resource + "/" + item[this.$config.primaryKey] + "/edit"
|
||||
});
|
||||
},
|
||||
remove(item) {
|
||||
if (window.confirm(this.$t("messages.confirm_delete"))) {
|
||||
this.$http
|
||||
.delete(this.resourceUri + "/" + item[this.$config.primaryKey])
|
||||
.then(() => {
|
||||
this.$snotify.success(this.$t("messages.deleted"));
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
},
|
||||
removeAll() {
|
||||
if (window.confirm(this.$t("messages.confirm_delete_all"))) {
|
||||
this.$http.delete(this.resourceUri).then(() => {
|
||||
this.$snotify.success(this.$t("messages.deleted_all"));
|
||||
this.fetch();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onSearch() {
|
||||
this.where = this.searchModel;
|
||||
this.fetch();
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.site.fetched && this.fetchGrid(true);
|
||||
|
||||
// console.log('mounted');
|
||||
},
|
||||
created() {
|
||||
// this.applyQuery();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
192
front/src/components/DataValue.vue
Normal file
192
front/src/components/DataValue.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="break-all data-value">
|
||||
|
||||
<template v-if="['html'].includes(field.type)">
|
||||
<div v-html="value" class=" data-value-html"></div>
|
||||
</template>
|
||||
<template v-else-if="['number'].includes(field.type)">
|
||||
<div class=" text-right">{{value}}</div>
|
||||
</template>
|
||||
<template v-else-if="['image'].includes(field.type)">
|
||||
<template v-if="field.multiple">
|
||||
<b-img class="type-image" :key="v" v-for="v in value" :src="preview(v)" v-bind="field" @click.stop="previewInModal(v)"/>
|
||||
</template>
|
||||
<b-img class="type-image" v-else :src="preview(value)" v-bind="field" fluid @click.stop="previewInModal(value)"/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="['audio', 'video'].includes(field.type)">
|
||||
<component :is="field.type" :src="value" controls />
|
||||
</template>
|
||||
<template v-else-if="['file'].includes(field.type)">
|
||||
<a target="blank" :href="value" v-if="value">
|
||||
<i class="fa fa-file"></i> {{String(value).split('/').pop()}}
|
||||
</a>
|
||||
<div v-else class="text-muted">
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="['link'].includes(field.type)">
|
||||
<a :class="field.classes" :href="value" :target="field.target">
|
||||
<i :class="field.icon" v-if="field.icon"></i>
|
||||
{{field.text || field.label}}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<template v-else-if="['switch', 'boolean', 'checkbox'].includes(field.type)">
|
||||
<b-badge :variant="value ? 'success' : 'danger'">
|
||||
{{value ? 'Yes' : 'No'}}
|
||||
</b-badge>
|
||||
</template>
|
||||
|
||||
<template v-else-if="field.refLabel">
|
||||
<template>
|
||||
{{_.get(model || {}, [name, ...field.refLabel.split('.')])}}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="field.ref">
|
||||
<template v-if="field.multiple">
|
||||
{{_.map(_.get(model || {}, field.ref.split('.')[0]), field.ref.split('.')[1]).join(',')}}
|
||||
</template>
|
||||
<template v-else-if="isIntl">
|
||||
{{_.get(model || {}, field.ref.split('.').concat([lang]))}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{_.get(model || {}, field.ref)}}
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="name === $config.primaryKey">
|
||||
<span v-b-tooltip.hover.top.d100 :title="value" v-if="value">
|
||||
{{String(shortId ? String(value).substr(-4) : value).toUpperCase()}}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="['date', 'datetime'].includes(field.type)">
|
||||
<span v-b-tooltip.hover.top.d100 :title="value" v-if="value">
|
||||
|
||||
<template v-if="_.isString(value)">
|
||||
{{$d(new Date(value), timeFormat)}}
|
||||
</template>
|
||||
<template v-else-if="_.isArray(value)">
|
||||
{{$d(new Date(value[0]), timeFormat)}} -
|
||||
{{$d(new Date(value[1]), timeFormat)}}
|
||||
</template>
|
||||
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{value}}
|
||||
</template>
|
||||
|
||||
<b-modal :title="field.label" v-model="showModal">
|
||||
<b-img :src="preview(previewValue)" class="my-2" center fluid />
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from "lodash";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
previewValue: null,
|
||||
showModal: false,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
|
||||
field: {
|
||||
required: true,
|
||||
type: Object
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
type: String
|
||||
},
|
||||
model: {
|
||||
required: true,
|
||||
type: Object
|
||||
},
|
||||
lang: {
|
||||
|
||||
},
|
||||
shortId: {
|
||||
required: false,
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
timeFormat() {
|
||||
return 'long'
|
||||
},
|
||||
isIntl(){
|
||||
return this.field.intl || this.field.multilingual
|
||||
},
|
||||
value() {
|
||||
// const path = this.name.replace(/\]/g, '').replace(/\[/g, '.').split('.').pop()
|
||||
// console.log(this.name)
|
||||
let value = _.get(this.model || {}, this.field.refLabel || this.field.ref || this.name);
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
if (['select', 'select2', 'radiolist', 'checkboxlist'].includes(this.field.type)) {
|
||||
const options = _.mapValues(
|
||||
_.keyBy(this.field.options, "value"),
|
||||
"text"
|
||||
);
|
||||
return options[value];
|
||||
}
|
||||
if (this.lang && this.isIntl) {
|
||||
return _.get(value, this.lang, null)
|
||||
}
|
||||
return value;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
previewInModal(value) {
|
||||
this.showModal = true
|
||||
this.previewValue = value
|
||||
},
|
||||
preview(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (typeof file === "string") {
|
||||
return file;
|
||||
}
|
||||
if (file instanceof File) {
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.data-table td .modal-body img{
|
||||
max-height: inherit;
|
||||
max-width: 100%;
|
||||
}
|
||||
.data-value-html {
|
||||
max-height: 500px;
|
||||
max-width: 420px;
|
||||
overflow-y: scroll;
|
||||
border: 1px solid #eee;
|
||||
|
||||
p {
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.data-value {
|
||||
.type-image {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
||||
131
front/src/components/DataView.vue
Normal file
131
front/src/components/DataView.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="page-view">
|
||||
<div class="data-view">
|
||||
<legend v-if="model[$config.primaryKey]">{{$t('actions.view')}}: {{model[$config.primaryKey]}}</legend>
|
||||
<table class="table ">
|
||||
<tbody>
|
||||
<tr v-for="(field, key) in fields" :key="key">
|
||||
<th style="min-width:120px">{{field.label || key}}</th>
|
||||
<td>
|
||||
<div v-if="['array'].includes(field.type)">
|
||||
<b-table :items="model[key]" :fields="field.fields">
|
||||
<template v-for="(child, k) in field.fields" :slot="k" slot-scope="row">
|
||||
<b-data-value :lang="currentLanguage" :field="child" :name="k" :key="k" :model="row.item" />
|
||||
</template>
|
||||
</b-table>
|
||||
</div>
|
||||
<div v-else>
|
||||
<b-data-value :lang="currentLanguage" :field="field" :name="key" :model="model" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div slot="footer">
|
||||
<b-btn @click="$router.go(-1)">{{$t('actions.back')}}</b-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BDataValue from "./DataValue";
|
||||
import _ from 'lodash'
|
||||
|
||||
import { mapState, mapGetters } from 'vuex'
|
||||
export default {
|
||||
components: {
|
||||
BDataValue
|
||||
},
|
||||
props: {
|
||||
resource: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
id: {
|
||||
default: '',
|
||||
required: true
|
||||
},
|
||||
viewPath: {
|
||||
type: String,
|
||||
default: "view",
|
||||
required: false
|
||||
},
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
choices: {},
|
||||
fields: {},
|
||||
model: {},
|
||||
errors: [],
|
||||
items: []
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
resourceUri() {
|
||||
let url = [this.site.resource_prefix, this.resource, this.id]
|
||||
.filter(v => v)
|
||||
.join("/");
|
||||
return url;
|
||||
},
|
||||
viewUri() {
|
||||
let url = [this.site.resource_prefix, this.resource, this.viewPath]
|
||||
.filter(v => v)
|
||||
.join("/");
|
||||
url += "?id=" + (this.id || "");
|
||||
return url;
|
||||
},
|
||||
with() {
|
||||
return _.filter(
|
||||
_.map(this.fields, (v) => v.ref && v.ref.replace(/\.\w+$/, ''))
|
||||
);
|
||||
},
|
||||
|
||||
...mapState(['site']),
|
||||
...mapGetters(["currentMenu", "currentLanguage"]),
|
||||
header() {
|
||||
return `
|
||||
''
|
||||
<small> ${this.resource.toUpperCase()} </small>
|
||||
`
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetch() {
|
||||
this.$http.get(this.resourceUri, {
|
||||
params: {
|
||||
query: { with: this.with }
|
||||
}
|
||||
}).then(({ data }) => {
|
||||
this.model = data;
|
||||
});
|
||||
},
|
||||
fetchView() {
|
||||
this.$http.get(this.viewUri).then(({ data }) => {
|
||||
this.fields = data.fields;
|
||||
delete this.fields._actions
|
||||
this.fetch();
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
id: "fetchForm",
|
||||
"site.fetched"(val){
|
||||
if (val) {
|
||||
this.fetchView(true)
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
},
|
||||
created() {
|
||||
this.site.fetched && this.fetchView();
|
||||
|
||||
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
42
front/src/components/FileManager.vue
Normal file
42
front/src/components/FileManager.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<b-modal id="file-manager" title="文件管理器">
|
||||
<b-table :items="items" :fields="fields">
|
||||
<b-data-value slot-scope="data" slot="url" name="url" :model="data.item" :field="fields.url"></b-data-value>
|
||||
</b-table>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
fields: {
|
||||
originalname: { label: "原文件名" },
|
||||
url: { type: "image", style: { height: "3em" } }
|
||||
},
|
||||
items: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(["site"]),
|
||||
fileUrl() {
|
||||
return [this.site.resource_prefix, "files"].filter(v => v).join("/");
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
"site.fetched": "fetch"
|
||||
},
|
||||
methods: {
|
||||
fetch() {
|
||||
this.$http.get(this.fileUrl).then(({ data }) => {
|
||||
this.items = data.data;
|
||||
});
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.site.fetched && this.fetch();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
35
front/src/components/Footer.vue
Normal file
35
front/src/components/Footer.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="app-footer">
|
||||
<footer class="bg-light" v-html="site.footer" v-if="site.footer"></footer>
|
||||
<footer class="bg-light" v-else>
|
||||
<span class="ml-auto">
|
||||
GitHub
|
||||
<a href="https://github.com/wxs77577/rest-admin">REST-ADMIN</a>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
export default {
|
||||
computed: {
|
||||
...mapState(["site"])
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.app-footer {
|
||||
// position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
z-index: 9;
|
||||
letter-spacing: 1px;
|
||||
|
||||
& > footer {
|
||||
// margin-left: 215px;
|
||||
padding: 0.5em;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
373
front/src/components/FormBuilder.vue
Normal file
373
front/src/components/FormBuilder.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div v-if="model">
|
||||
<component
|
||||
:is="tag"
|
||||
ref="form"
|
||||
:action="actionUrl"
|
||||
:method="method"
|
||||
class="form form-inline"
|
||||
@submit.prevent="handleSubmit"
|
||||
v-if="inline"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<input type="hidden" name="token" :value="auth.token" />
|
||||
|
||||
<template v-for="(field, name) in fields">
|
||||
<label
|
||||
:for="'input_' + name"
|
||||
class="m-1"
|
||||
:key="name"
|
||||
v-if="field.label !== false"
|
||||
>{{field.label || $inflection.titleize(name)}}</label>
|
||||
<b-form-field
|
||||
:languages="languages"
|
||||
:parent="parent || model"
|
||||
class="m-1 mr-4"
|
||||
@input="setValue(name, arguments[0], arguments[1])"
|
||||
:value="model[name]"
|
||||
:id="getFieldId(name)"
|
||||
:name="name"
|
||||
:field="field"
|
||||
:state="!hasError(name)"
|
||||
:key="id + '_' +name"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<slot name="actions">
|
||||
<b-button type="submit" variant="primary" ref="submitButton" class="mr-1">{{submitText}}</b-button>
|
||||
<b-button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="$router.go(-1)"
|
||||
v-if="backText"
|
||||
>{{backText}}</b-button>
|
||||
<slot name="extra-buttons"></slot>
|
||||
</slot>
|
||||
</component>
|
||||
|
||||
<component
|
||||
:is="tag"
|
||||
ref="form"
|
||||
:action="actionUrl"
|
||||
:method="method"
|
||||
class="form"
|
||||
@submit.prevent="handleSubmit"
|
||||
enctype="multipart/form-data"
|
||||
v-else
|
||||
>
|
||||
<input type="hidden" name="token" :value="auth.token" />
|
||||
<b-tabs
|
||||
class="my-3"
|
||||
v-if="groupBy"
|
||||
:class="{'hide-group-name': _.get(layout, 'hideGroupName')}"
|
||||
>
|
||||
<b-tab
|
||||
v-for="(subFields, tabName) in groupedFields"
|
||||
:title="_.get(layout, `tabs.${tabName}.name`) || tabName || $t('messages.default')"
|
||||
:key="tabName"
|
||||
>
|
||||
<div class="row form-cols">
|
||||
<b-col :md="_.get(layout, `tabs.${tabName}.cols`, 12)">
|
||||
<b-row class="mt-4">
|
||||
<template v-for="(field, name) in subFields">
|
||||
<b-form-group
|
||||
:class="getClass(field)"
|
||||
v-if="isShowField(field) && model"
|
||||
:state="!hasError(name)"
|
||||
:key="id + '_' +name"
|
||||
v-bind="field"
|
||||
:label-for="'input_' + name"
|
||||
:label="field.label !== false ? (field.label || $inflection.titleize(name)) : ''"
|
||||
>
|
||||
<div class>
|
||||
<b-form-field
|
||||
:languages="languages"
|
||||
:class="getInputClass(field)"
|
||||
:parent="parent || model"
|
||||
@input="setValue(name, arguments[0], arguments[1])"
|
||||
:value="model[name]"
|
||||
:name="name"
|
||||
:field="field"
|
||||
:state="!hasError(name)"
|
||||
:id="'input_' + name"
|
||||
/>
|
||||
</div>
|
||||
</b-form-group>
|
||||
</template>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col
|
||||
:md="12 - _.get(layout, `tabs.${tabName}.cols`, 0)"
|
||||
v-if="!!_.get(layout, `tabs.${tabName}.right`)"
|
||||
>
|
||||
<div v-html="_.get(layout, `tabs.${tabName}.right`)"></div>
|
||||
</b-col>
|
||||
</div>
|
||||
</b-tab>
|
||||
</b-tabs>
|
||||
<div class="row" v-else>
|
||||
<template v-for="(field, name) in fields">
|
||||
<b-form-group
|
||||
:class="getClass(field)"
|
||||
v-if="isShowField(field) && model"
|
||||
:state="!hasError(name)"
|
||||
:key="[id,subForm,name].join()"
|
||||
v-bind="field"
|
||||
:label-for="getFieldId(name)"
|
||||
:label="field.label !== false ? (field.label || $inflection.titleize(name)) : ''"
|
||||
>
|
||||
<div class>
|
||||
<b-form-field
|
||||
:languages="languages"
|
||||
:class="getInputClass(field)"
|
||||
:parent="parent || model"
|
||||
@input="setValue(name, arguments[0], arguments[1])"
|
||||
:value="model[name]"
|
||||
:name="getFieldName(name)"
|
||||
:field="field"
|
||||
:state="!hasError(name)"
|
||||
:id="getFieldId(name)"
|
||||
/>
|
||||
</div>
|
||||
</b-form-group>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<slot name="actions" v-if="!subForm">
|
||||
<b-button type="submit" variant="primary" class="mr-1" ref="submitButton">{{submitText}}</b-button>
|
||||
<b-button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@click="$router.go(-1)"
|
||||
v-if="backText"
|
||||
>{{backText}}</b-button>
|
||||
</slot>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from "lodash";
|
||||
|
||||
export default {
|
||||
name: "b-form-builder",
|
||||
components: {},
|
||||
props: {
|
||||
parent: {},
|
||||
subForm: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default() {
|
||||
return "form_" + parseInt(Math.random() * 9999);
|
||||
}
|
||||
},
|
||||
auth: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
col: {
|
||||
type: Number,
|
||||
default: 12
|
||||
},
|
||||
languages: {},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
useFormData: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
submitRawForm: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
groupBy: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
value: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
fields: {
|
||||
required: true,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
required: false,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
required: false
|
||||
},
|
||||
beforeSubmit: {
|
||||
type: Function,
|
||||
required: false
|
||||
},
|
||||
action: {},
|
||||
method: {
|
||||
default: "post"
|
||||
},
|
||||
submitText: {
|
||||
default() {
|
||||
return this.$t ? this.$t("actions.save") : "Submit";
|
||||
}
|
||||
},
|
||||
backText: {
|
||||
default() {
|
||||
return this.$t ? this.$t("actions.back") : "Back";
|
||||
}
|
||||
},
|
||||
|
||||
successMessage: {
|
||||
default() {
|
||||
return this.$t ? this.$t("messages.succeed") : "Succeed";
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
model: null,
|
||||
errors: []
|
||||
};
|
||||
},
|
||||
watch: {},
|
||||
computed: {
|
||||
tag() {
|
||||
return this.subForm ? "div" : "form";
|
||||
},
|
||||
actionUrl() {
|
||||
return global.API_URI + this.action;
|
||||
},
|
||||
groupedFields() {
|
||||
const ret = {};
|
||||
_.keys(_.groupBy(this.fields, this.groupBy)).map(v => {
|
||||
let tabName = v;
|
||||
if (v === "undefined") {
|
||||
v = null;
|
||||
tabName = this.$t("messages.default");
|
||||
}
|
||||
ret[tabName] = _.pickBy(this.fields, field => field.group == v);
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getFieldId(name) {
|
||||
if (this.subForm) {
|
||||
return `input_${this.subForm}_${name}`;
|
||||
}
|
||||
return `input_${name}`;
|
||||
},
|
||||
getFieldName(name) {
|
||||
if (this.subForm) {
|
||||
return `${this.subForm}[${name}]`;
|
||||
}
|
||||
return name;
|
||||
},
|
||||
setValue(name, value, lang) {
|
||||
const isIntl = this.fields[name].multilingual || this.fields[name].intl;
|
||||
if (!isIntl) {
|
||||
this.$set(this.model, name, value);
|
||||
// _.set(this.model, name, value);
|
||||
} else if (lang && !_.isObject(this.model[name])) {
|
||||
this.$set(this.model, name, {});
|
||||
} else {
|
||||
this.$set(this.model[name], lang, value);
|
||||
}
|
||||
return this.$emit("input", this.model);
|
||||
},
|
||||
titlize() {},
|
||||
isShowField(field) {
|
||||
return (
|
||||
!field.showWhen || this.model[field.showWhen] || eval(field.showWhen)
|
||||
);
|
||||
},
|
||||
getInputClass() {
|
||||
return [];
|
||||
// const classNames = [];
|
||||
// classNames.push(`col-lg-${field.input_cols ? field.input_cols : "12"}`);
|
||||
// return classNames;
|
||||
},
|
||||
getClass(field) {
|
||||
const cols = field.cols ? field.cols : 12;
|
||||
const classNames = ["col-lg-" + cols, "col-" + Math.min(12, cols * 2)];
|
||||
return classNames;
|
||||
},
|
||||
hasError(name) {
|
||||
return _.find(this.errors, v => v.field == name);
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs.submitButton.click();
|
||||
},
|
||||
handleSubmit() {
|
||||
if (this.beforeSubmit) {
|
||||
const ret = this.beforeSubmit(this.model);
|
||||
if (ret === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this.submitRawForm) {
|
||||
this.$refs.form.submit();
|
||||
return true;
|
||||
}
|
||||
if (this.onSubmit) {
|
||||
return this.onSubmit(this.model);
|
||||
}
|
||||
const methodName = String(this.method).toLowerCase();
|
||||
// console.log(this.$refs.form);
|
||||
let formData = this.model;
|
||||
if (this.useFormData) {
|
||||
formData = new FormData();
|
||||
_.mapValues(this.model, (v, k) => formData.append(k, v));
|
||||
}
|
||||
this.$http[methodName](this.action, formData)
|
||||
.then(({ data }) => {
|
||||
if (this.successMessage) {
|
||||
this.$snotify.success(this.successMessage);
|
||||
}
|
||||
this.errors = [];
|
||||
this.$emit("success", data);
|
||||
})
|
||||
.catch(({ data, status }) => {
|
||||
if (status == 422) {
|
||||
this.errors = data.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.model = Object.assign({}, this.value);
|
||||
|
||||
for (let [k, v] of Object.entries(this.fields)) {
|
||||
if (v.type === "object" && !this.model[k]) {
|
||||
this.$set(this.model, k, {});
|
||||
}
|
||||
}
|
||||
|
||||
this.$watch(
|
||||
"value",
|
||||
val => {
|
||||
this.model = Object.assign({}, val);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
// global.console.log(this.fields, this.model)
|
||||
},
|
||||
created() {}
|
||||
};
|
||||
</script>
|
||||
|
||||
635
front/src/components/FormField.vue
Normal file
635
front/src/components/FormField.vue
Normal file
@@ -0,0 +1,635 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="languages mb-1" v-if="isIntl">
|
||||
<span
|
||||
class="badge mr-1 mb-0 pointer"
|
||||
:class="`badge-${currentLanguage === key ? 'primary' : 'secondary'}`"
|
||||
v-for="(lang, key) in languages"
|
||||
:key="key"
|
||||
@click="changeLanguage(key, name)"
|
||||
>{{lang}}</span>
|
||||
</div>
|
||||
<b-form-select
|
||||
v-if="['select'].includes(field.type)"
|
||||
:formatter="getFormatter(field, value)"
|
||||
:id="id"
|
||||
:options="options"
|
||||
v-bind="field"
|
||||
v-model="model"
|
||||
|
||||
:name="name"
|
||||
></b-form-select>
|
||||
<div v-else-if="['select2'].includes(field.type)">
|
||||
<b-select v-model="model" v-bind="field" :parent="parent"></b-select>
|
||||
</div>
|
||||
<b-tree-select
|
||||
:normalizer="treeSelectNormalizer"
|
||||
value-consists-of="LEAF_PRIORITY"
|
||||
v-else-if="['tree', 'treeselect'].includes(field.type)"
|
||||
v-bind="field"
|
||||
v-model="model"
|
||||
/>
|
||||
<!-- <b-select v-if="['select', 'select2'].includes(field.type)" track-by="value" label="text" @input="model = arguments[0]" :id="id" v-bind="field" :title="value" /> -->
|
||||
|
||||
<b-date-picker
|
||||
v-else-if="['date', 'datetime'].includes(field.type)"
|
||||
:name="name"
|
||||
v-bind="field"
|
||||
v-model="model"
|
||||
/>
|
||||
<b-form-radio-group
|
||||
v-else-if="['radiolist'].includes(field.type)"
|
||||
:name="name"
|
||||
v-bind="field"
|
||||
v-model="model"
|
||||
>
|
||||
<!-- <b-form-radio :key="choice.value" :value="choice.value" v-for="choice in field.options">{{choice.text}}</b-form-radio> -->
|
||||
</b-form-radio-group>
|
||||
<b-form-checkbox-group
|
||||
:name="name"
|
||||
v-else-if="['checkboxlist'].includes(field.type)"
|
||||
v-bind="field"
|
||||
v-model="model"
|
||||
>
|
||||
<!-- <b-form-checkbox :key="choice.value" :value="choice.value" v-for="choice in field.options">{{choice.text}}</b-form-checkbox> -->
|
||||
</b-form-checkbox-group>
|
||||
<div v-else-if="['checkboxtable'].includes(field.type)" class="checkboxtable">
|
||||
<div v-for="(options, group) in groupedOptions" :key="group" class="mt-1">
|
||||
<b-form-checkbox-group :name="name" :options="options" v-bind="field" v-model="model"></b-form-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<b-form-textarea
|
||||
:name="name"
|
||||
v-else-if="['textarea'].includes(field.type)"
|
||||
:id="id"
|
||||
v-model="model"
|
||||
v-bind="field"
|
||||
:rows="field.rows || 3"
|
||||
/>
|
||||
<div v-else-if="['file-picker'].includes(field.type)">
|
||||
<b-btn v-b-modal.file-manager>选择文件</b-btn>
|
||||
</div>
|
||||
<!-- <b-uploader v-else-if="['image', 'file', 'audio'].includes(field.type)" :id="id" v-model="model" v-bind="field" /> -->
|
||||
|
||||
<component
|
||||
:is="field.autoUpload === false ? 'b-form-file' : 'b-form-uploader'"
|
||||
v-else-if="['image', 'file', 'audio', 'video'].includes(field.type)"
|
||||
:field="field"
|
||||
v-model="model"
|
||||
:id="id"
|
||||
:name="name"
|
||||
:parent="parent"
|
||||
/>
|
||||
<div v-else-if="['switch', 'checkbox'].includes(field.type)">
|
||||
<b-form-checkbox
|
||||
variant="success"
|
||||
v-bind="field"
|
||||
size="lg"
|
||||
pill
|
||||
type="3d"
|
||||
:id="id"
|
||||
v-model="model"
|
||||
/>
|
||||
</div>
|
||||
<!-- <b-ueditor :state="state" v-else-if="['wysiwyg', 'html'].includes(field.type)" :id="id" v-bind="field" v-model="model" /> -->
|
||||
|
||||
<div v-else-if="['wysiwyg', 'html'].includes(field.type)">
|
||||
<!-- <b-btn :id="`cropper_${id}`" variant="success">图片裁剪助手</b-btn> -->
|
||||
<avatar-cropper
|
||||
v-if="field.cropper"
|
||||
:trigger="`#cropper_${id}`"
|
||||
v-bind="cropperOptions"
|
||||
ref="cropper"
|
||||
@uploaded="cropperUploaded"
|
||||
@completed="cropperUploadComplete"
|
||||
/>
|
||||
<b-html-editor
|
||||
ref="editor"
|
||||
:state="state"
|
||||
:id="id"
|
||||
v-bind="field"
|
||||
v-model="model"
|
||||
:content="model"
|
||||
@open-cropper="$refs.cropper && $refs.cropper.pickImage()"
|
||||
@change="htmlEditorInput"
|
||||
@keyup.native.enter="wrapFirstLine"
|
||||
/>
|
||||
<!-- <b-cropper v-if="field.showCropper" ref="cropper" ></b-cropper> -->
|
||||
</div>
|
||||
<div v-else-if="['json'].includes(field.type)">
|
||||
<b-form-textarea :id="id" v-model="model" v-bind="field" :rows="field.rows || 5"/>
|
||||
<!-- <v-jsoneditor v-if="model" :value="JSON.parse(model)"
|
||||
@input="model = JSON.stringify(arguments[0])"
|
||||
:options="{}" />-->
|
||||
</div>
|
||||
<div v-else-if="field.fields">
|
||||
<div v-if="['array'].includes(field.type) || field.is_array || parent.is_array">
|
||||
<b-table
|
||||
hover
|
||||
bordered
|
||||
:items="model"
|
||||
:fields="myFields"
|
||||
v-if="field.is_table || parent.is_table"
|
||||
>
|
||||
<template v-for="(child, k) in myFields" :slot="k" slot-scope="row">
|
||||
<b-form-field
|
||||
:parent="parent"
|
||||
v-model="model[row.index][k]"
|
||||
:field="child"
|
||||
:name="`input_${row.index}_${k}`"
|
||||
:key="`input_${row.index}_${k}`"
|
||||
:id="`input_${row.index}_${k}`"
|
||||
/>
|
||||
</template>
|
||||
<template slot="HEAD__actions">
|
||||
<b-btn size="sm" @click="addRow">
|
||||
<i class="icon-plus"></i>
|
||||
{{$t('actions.add')}}
|
||||
</b-btn>
|
||||
</template>
|
||||
<template slot="_actions" slot-scope="row">
|
||||
<b-btn size="sm" @click="model.splice(row.index + 1, 0, {});">
|
||||
<i class="icon-plus"></i>
|
||||
{{$t('actions.add')}}
|
||||
</b-btn>
|
||||
<b-btn size="sm" @click="model.splice(row.index, 1);">
|
||||
<i class="icon-trash"></i>
|
||||
{{$t('actions.delete')}}
|
||||
</b-btn>
|
||||
</template>
|
||||
</b-table>
|
||||
<b-draggable v-model="model" v-else>
|
||||
<transition-group tag="div" class="row">
|
||||
<b-col
|
||||
v-for="(item, i) in model"
|
||||
:key="`draggable-${name}-${i}`"
|
||||
cols
|
||||
:lg="field.item_cols || 6"
|
||||
>
|
||||
<b-card class="mb-4">
|
||||
<b-row slot="header" class="justify-content-between">
|
||||
<b-col>No. {{i + 1}}</b-col>
|
||||
<b-col right class="text-right">
|
||||
<b-btn size="sm" @click="model.splice(i, 1)">
|
||||
<i class="icon-trash"></i>
|
||||
{{$t('actions.delete')}}
|
||||
</b-btn>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-form-group
|
||||
v-for="(child, key) in myFields"
|
||||
:key="key"
|
||||
v-bind="child"
|
||||
:label-for="`input_${name}_${i}_${key}`"
|
||||
>
|
||||
<b-form-field
|
||||
v-model="model[i][key]"
|
||||
:parent="parent"
|
||||
:name="`${name}.${i}.${key}`"
|
||||
:field="child"
|
||||
:id="`input_${name}_${i}_${key}`"
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-card>
|
||||
</b-col>
|
||||
<b-col
|
||||
cols
|
||||
:lg="field.item_cols || 6"
|
||||
:key="-1"
|
||||
class="d-flex align-items-center justify-content-center"
|
||||
>
|
||||
<b-btn
|
||||
size="lg"
|
||||
class="p-5"
|
||||
block
|
||||
@click="model = !model? [] : model; model.push({})"
|
||||
>
|
||||
<i class="fa fa-plus"></i>
|
||||
</b-btn>
|
||||
</b-col>
|
||||
</transition-group>
|
||||
</b-draggable>
|
||||
</div>
|
||||
<div v-else-if="['object'].includes(field.type)">
|
||||
<b-card>
|
||||
<b-form-builder
|
||||
:sub-form="name || ''"
|
||||
v-model="model"
|
||||
:languages="languages"
|
||||
:fields="myFields"
|
||||
:parent="parent"
|
||||
ref="subForm"
|
||||
></b-form-builder>
|
||||
<!-- <b-form-group v-for="(child, key) in myFields" :key="key" v-bind="child" :label-for="`input_${name}_${key}`">
|
||||
|
||||
<b-form-field :value="_.get(model, `${key}`)"
|
||||
@input="model[key] = arguments[0]"
|
||||
:parent="parent" :name="`${name}_${key}`" :field="child" :id="`input_${name}_${key}`" />
|
||||
</b-form-group>-->
|
||||
</b-card>
|
||||
</div>
|
||||
<div v-else>
|
||||
<b-form-group
|
||||
v-for="(child, key) in myFields"
|
||||
:key="key"
|
||||
v-bind="child"
|
||||
:label-for="`input_${name}_${key}`"
|
||||
>
|
||||
<b-form-field
|
||||
v-model="model[key]"
|
||||
:parent="parent"
|
||||
:name="key"
|
||||
:field="child"
|
||||
:id="`input_${name}_${key}`"
|
||||
/>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</div>
|
||||
<b-input-group v-else>
|
||||
<b-input-group-prepend is-text v-if="field.prependIcon || field.prepend">
|
||||
<i :class="field.prependIcon" v-if="field.prependIcon"></i>
|
||||
<span v-else v-html="field.prepend"></span>
|
||||
</b-input-group-prepend>
|
||||
<b-form-input
|
||||
:state="state"
|
||||
:id="id"
|
||||
:name="name"
|
||||
v-bind="field"
|
||||
v-model="model"
|
||||
:formatter="getFormatter(field, value)"
|
||||
/>
|
||||
<b-input-group-append is-text v-if="field.appendIcon || field.append">
|
||||
<i :class="field.appendIcon" v-if="field.appendIcon"></i>
|
||||
<span v-else v-html="field.append"></span>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</div>
|
||||
</template>
|
||||
<style>
|
||||
.checkboxtable .btn-group > .btn:first-child {
|
||||
text-align: center;
|
||||
width: 10em;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.vue-html5-editor .content {
|
||||
max-height: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import BDraggable from "vuedraggable";
|
||||
import BTreeSelect from "@riophae/vue-treeselect";
|
||||
import "@riophae/vue-treeselect/dist/vue-treeselect.min.css";
|
||||
// import BSelect from "vue-multiselect"
|
||||
import BSelect from "./FormSelect2";
|
||||
// import BSelect from "@alfsnd/vue-bootstrap-select";
|
||||
// import "vue-multiselect/dist/vue-multiselect.min.css"
|
||||
import BDatePicker from "vue2-datepicker";
|
||||
// import BUeditor from "./UEditor"
|
||||
import BFormUploader from "./FormUploader";
|
||||
import VueHtml5Editor from "vue-html5-editor";
|
||||
// import BJsonEditor from "./JsonEditor"
|
||||
// import BJsonEditor from "vue-jsoneditor"
|
||||
import _ from "lodash";
|
||||
|
||||
// import "jsoneditor/dist/jsoneditor.min.css"
|
||||
|
||||
// Vue.use(BJsonEditor);
|
||||
export default {
|
||||
components: {
|
||||
// BUeditor,
|
||||
BDatePicker,
|
||||
BSelect,
|
||||
BFormUploader,
|
||||
// BJsonEditor,
|
||||
BDraggable,
|
||||
BTreeSelect
|
||||
},
|
||||
props: {
|
||||
languages: {},
|
||||
id: {
|
||||
required: true
|
||||
},
|
||||
parent: {},
|
||||
value: {},
|
||||
field: {},
|
||||
state: {},
|
||||
name: {}
|
||||
},
|
||||
computed: {
|
||||
cropperOptions() {
|
||||
return {
|
||||
"upload-url": global.API_URI + "upload",
|
||||
"upload-headers": {
|
||||
Authorization: "Bearer " + this.$store.state.auth.token
|
||||
},
|
||||
"upload-form-name": "file",
|
||||
"upload-form-data": {
|
||||
from: "cropper"
|
||||
},
|
||||
"cropper-options": {
|
||||
viewMode: 2,
|
||||
aspectRatio: _.get(this.field, "cropper.ratio", 1)
|
||||
},
|
||||
"output-options": this.field.cropper,
|
||||
labels: this.field.labels || { submit: "提交", cancel: "取消" }
|
||||
};
|
||||
},
|
||||
isSelect() {
|
||||
return ["select", "select2"].includes(this.field.type);
|
||||
},
|
||||
isSelect2() {
|
||||
return ["select2"].includes(this.field.type);
|
||||
},
|
||||
groupedOptions() {
|
||||
return _.groupBy(this.options, "group");
|
||||
},
|
||||
myFields() {
|
||||
let fields = this.field.fields;
|
||||
if (typeof fields == "string") {
|
||||
const rel = this.parent[fields];
|
||||
if (!rel) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
fields = JSON.parse(rel);
|
||||
} catch (e) {
|
||||
fields = {};
|
||||
}
|
||||
}
|
||||
if (this.parent.is_table) {
|
||||
fields._actions = { label: this.$t("actions.actions") };
|
||||
}
|
||||
|
||||
return fields;
|
||||
},
|
||||
description() {
|
||||
if (this.field.limit) {
|
||||
const { width, height } = this.field.limit;
|
||||
if (width && height) {
|
||||
return `尺寸:${width}x${height}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return this.field.description;
|
||||
},
|
||||
filteredValue() {
|
||||
let defaultValue = this.value;
|
||||
if (!this.defaultValue) {
|
||||
if (["object", "json"].includes(this.field.type)) {
|
||||
defaultValue = {};
|
||||
}
|
||||
if (["array"].includes(this.field.type) || this.field.multiple) {
|
||||
defaultValue = [];
|
||||
}
|
||||
}
|
||||
// console.log(defaultValue);
|
||||
return defaultValue;
|
||||
},
|
||||
isArrayValue() {
|
||||
return (
|
||||
this.field.multiple ||
|
||||
this.field.is_array ||
|
||||
this.field.type == "array" ||
|
||||
this.field.is_table
|
||||
);
|
||||
},
|
||||
isIntl() {
|
||||
return this.field.intl || this.field.multilingual;
|
||||
},
|
||||
selectedValue1() {
|
||||
let value = this.initSelectedValue
|
||||
if (this.isArrayValue) {
|
||||
value = _.filter(
|
||||
this.options,
|
||||
v => this.value && this.value.includes(v.value)
|
||||
);
|
||||
} else {
|
||||
value = _.find(this.options, v => this.value == v.value);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
model: {
|
||||
get() {
|
||||
const isArray =
|
||||
this.field.multiple ||
|
||||
this.field.is_array ||
|
||||
this.field.type == "array" ||
|
||||
this.field.is_table;
|
||||
const isObject = this.field.type == "object";
|
||||
let ret = this.value;
|
||||
if (!this.value) {
|
||||
if (isArray) {
|
||||
ret = [];
|
||||
} else if (isObject) {
|
||||
ret = {};
|
||||
}
|
||||
}
|
||||
if (this.isIntl) {
|
||||
// console.log(this.name, ret, this.currentLanguage);
|
||||
return _.get(ret, this.currentLanguage, "");
|
||||
}
|
||||
return ret;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("input", value, this.currentLanguage);
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
const isArray =
|
||||
this.field.multiple ||
|
||||
this.field.is_array ||
|
||||
this.field.type == "array" ||
|
||||
this.field.is_table;
|
||||
return {
|
||||
currentLanguage: this.field.currentLanguage || "en",
|
||||
options: this.field.options || [],
|
||||
initSelectedValue: isArray && !this.value ? [] : this.value,
|
||||
selectedValue: isArray && !this.value ? [] : this.value,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
addRow() {
|
||||
if (!this.parent[this.name]) {
|
||||
this.$set(this.parent, this.name, []);
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.model.push({});
|
||||
});
|
||||
},
|
||||
initEditor() {
|
||||
const language = "zh-cn";
|
||||
window.document.execCommand("defaultParagraphSeparator", false, "p");
|
||||
|
||||
const visibleModules = this.field.modules || [
|
||||
"text",
|
||||
// "color",
|
||||
// "font",
|
||||
"heading",
|
||||
"align",
|
||||
"list",
|
||||
"link",
|
||||
"unlink",
|
||||
"tabulation",
|
||||
"image",
|
||||
// this.field.cropper ? "cropper" : "image",
|
||||
"hr",
|
||||
"eraser",
|
||||
"undo",
|
||||
"full-screen",
|
||||
"cropper"
|
||||
|
||||
// "info",
|
||||
];
|
||||
const cropperClass = _.get(
|
||||
this.field,
|
||||
"cropper.icon",
|
||||
"fa fa-crop text-danger"
|
||||
);
|
||||
|
||||
Vue.use(VueHtml5Editor, {
|
||||
name: "b-html-editor",
|
||||
language,
|
||||
showModuleName: false,
|
||||
modules: [
|
||||
{
|
||||
name: "cropper",
|
||||
icon: cropperClass,
|
||||
i18n: "cropper",
|
||||
show: true,
|
||||
handler: function(editor) {
|
||||
editor.$emit("open-cropper");
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
icon: "fa fa-header",
|
||||
i18n: "heading",
|
||||
show: true,
|
||||
dashboard: {
|
||||
template: `
|
||||
<div>
|
||||
<button v-for="h in 6" type="button" @click="setHeading(h)">H{{h}}</button>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
setHeading(heading) {
|
||||
this.$parent.execCommand("formatBlock", `h${heading}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
i18n: {
|
||||
"zh-cn": {
|
||||
cropper: "图片裁剪",
|
||||
heading: "标题"
|
||||
}
|
||||
},
|
||||
image: {
|
||||
sizeLimit: 10 * 1024 * 1024,
|
||||
upload: {
|
||||
url: global.API_URI + "upload",
|
||||
headers: {
|
||||
Authorization: "Bearer " + this.$store.state.auth.token
|
||||
},
|
||||
fieldName: "file"
|
||||
},
|
||||
// compress: {
|
||||
// width: 1600,
|
||||
// height: 1600,
|
||||
// quality: 80
|
||||
// },
|
||||
uploadHandler(res) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(res);
|
||||
} catch (e) {
|
||||
this.$notify.error("上传失败");
|
||||
}
|
||||
return data.url;
|
||||
}
|
||||
},
|
||||
visibleModules: visibleModules
|
||||
});
|
||||
},
|
||||
cropperUploaded(res) {
|
||||
this.$refs.editor.execCommand("insertHTML", `<img src="${res.url}" />`);
|
||||
},
|
||||
cropperUploadComplete(data) {
|
||||
if (data.message) {
|
||||
this.$snotify.error(data.message);
|
||||
}
|
||||
},
|
||||
changeLanguage(lang) {
|
||||
this.currentLanguage = lang;
|
||||
// this.$emit('change-language', lang, name)
|
||||
// global.console.log(lang, name)
|
||||
},
|
||||
htmlEditorInput(value) {
|
||||
this.$emit("input", value, this.currentLanguage);
|
||||
},
|
||||
wrapFirstLine() {
|
||||
// const value = String(el.target.innerHTML).replace(/^\s*(.+?)(<?)/i, '<p> $1 </p>$2')
|
||||
// this.$emit('input', value)
|
||||
},
|
||||
treeSelectNormalizer(row) {
|
||||
return {
|
||||
id: row.value,
|
||||
label: row.text
|
||||
};
|
||||
},
|
||||
getFormatter(field) {
|
||||
if (field.format) {
|
||||
return eval(field.format);
|
||||
}
|
||||
return v => v;
|
||||
},
|
||||
|
||||
fetchAjaxOptions(query = {}) {
|
||||
const params = this.field.ajaxOptions;
|
||||
const { url, resource, where = {}, text, depends } = params;
|
||||
params.where = Object.assign({}, where, query);
|
||||
if (this.q) {
|
||||
params.q = this.q;
|
||||
}
|
||||
const apiUrl = url
|
||||
? _.template(url)({ item: this.parent })
|
||||
: resource + "/options";
|
||||
this.$http.get(apiUrl, { params }).then(({ data }) => {
|
||||
this.options = data;
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.field.type == "html") {
|
||||
// window.onscroll = () => {
|
||||
// const editor = this.$refs.editor.$el
|
||||
// const offsetTop = editor.getClientRects()[0].top
|
||||
// // const scrollTop = document.documentElement.scrollTop
|
||||
// if (offsetTop <= 0) {
|
||||
// editor.classList.toggle()
|
||||
// }
|
||||
// // global.console.log(scrollTop, offsetTop)
|
||||
// }
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.field.type == "html") {
|
||||
this.initEditor();
|
||||
}
|
||||
if (this.field.ajaxOptions && this.field.ajaxOptions.search !== true) {
|
||||
this.fetchAjaxOptions();
|
||||
}
|
||||
if (this.isSelect2) {
|
||||
// this.initOptionsForSelect2();
|
||||
|
||||
// this.$watch("options", () => {
|
||||
|
||||
// });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
93
front/src/components/FormSelect.vue
Normal file
93
front/src/components/FormSelect.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<b-dropdown split variant="primary" no-caret1 class="selectx">
|
||||
<template slot="button-content">
|
||||
<span>
|
||||
{{model}}
|
||||
<!-- <b-btn-close></b-btn-close> -->
|
||||
</span>
|
||||
<!-- <b-form-input class="border-0 w-auto d-inline-block" v-model="q"></b-form-input> -->
|
||||
</template>
|
||||
<b-dropdown-item-button
|
||||
@click="choose(item)"
|
||||
v-for="item in options"
|
||||
:key="item.value"
|
||||
:active="getIsActive(item)"
|
||||
>{{item.text}}</b-dropdown-item-button>
|
||||
<!-- <b-dropdown-item>Second Action</b-dropdown-item>
|
||||
<b-dropdown-item>Third Action</b-dropdown-item>
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<b-dropdown-item active>Active action</b-dropdown-item>
|
||||
<b-dropdown-item disabled>Disabled action</b-dropdown-item>-->
|
||||
</b-dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {},
|
||||
options: {},
|
||||
multiple: { type: Boolean }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
q: ""
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getIsActive(item) {
|
||||
if (this.multiple) {
|
||||
return (this.model || []).includes(item.value);
|
||||
} else {
|
||||
return this.model === item.value;
|
||||
}
|
||||
},
|
||||
choose(item) {
|
||||
const val = item.value;
|
||||
if (this.multiple) {
|
||||
let values = this.model.slice(0);
|
||||
const i = values.findIndex(val);
|
||||
// console.log(i);
|
||||
if (i) {
|
||||
values.splice(i, 1);
|
||||
} else {
|
||||
values.push(val);
|
||||
}
|
||||
return this.$emit("input", values);
|
||||
}
|
||||
this.$emit("input", val);
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
values() {},
|
||||
model: {
|
||||
get() {
|
||||
if (this.multiple && !this.value) {
|
||||
return [];
|
||||
}
|
||||
const selected = this.options.find(v => v.value === this.value)
|
||||
return selected ? selected.text : null;
|
||||
},
|
||||
set(val) {
|
||||
if (this.multiple) {
|
||||
let values = this.model.slice(0);
|
||||
const i = values.findIndex(val);
|
||||
if (i) {
|
||||
values.splice(i, 1);
|
||||
} else {
|
||||
values.push(val);
|
||||
}
|
||||
return this.$emit("input", values);
|
||||
}
|
||||
this.$emit("input", val);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.selectx{
|
||||
/* min-width:15em; */
|
||||
/* text-align: left; */
|
||||
}
|
||||
</style>
|
||||
123
front/src/components/FormSelect2.vue
Normal file
123
front/src/components/FormSelect2.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<v-select
|
||||
v-model="model"
|
||||
@search="getAjaxOptions"
|
||||
label="text"
|
||||
:options="newOptions"
|
||||
:multiple="multiple"
|
||||
>
|
||||
<div slot="no-options">{{noOptionsText}}</div>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VSelect from "vue-select";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
VSelect
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newOptions: this.options.slice(0),
|
||||
q: ""
|
||||
};
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
deafult() {
|
||||
return this.multiple ? [] : null;
|
||||
}
|
||||
},
|
||||
options: {
|
||||
default: () => []
|
||||
},
|
||||
name: {},
|
||||
multiple: {},
|
||||
field: {},
|
||||
parent: {},
|
||||
ajaxOptions: {},
|
||||
noOptionsText: {
|
||||
default() {
|
||||
return this.$t("messages.no_options_text");
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
if (this.multiple) {
|
||||
return this.newOptions.filter(v => this.value.includes(v.value));
|
||||
} else {
|
||||
return this.newOptions.find(v => this.value === v.value);
|
||||
}
|
||||
},
|
||||
set(val) {
|
||||
if (val === null || val.value === null) {
|
||||
return this.$emit("input", null);
|
||||
}
|
||||
let ret;
|
||||
if (this.multiple) {
|
||||
const isChanged = JSON.stringify(val) !== JSON.stringify(this.value);
|
||||
if (isChanged) {
|
||||
ret = val.map(v => v.value);
|
||||
}
|
||||
} else {
|
||||
ret = val.value;
|
||||
}
|
||||
if (ret !== null) {
|
||||
this.$emit("input", ret);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAjaxOptions(q) {
|
||||
this.q = q;
|
||||
this.fetchOptions();
|
||||
},
|
||||
initOptionsForSelect2() {
|
||||
const parentOptions = this.parent[this.name + "_data"];
|
||||
if (parentOptions) {
|
||||
this.newOptions = this.options.concat(parentOptions);
|
||||
}
|
||||
},
|
||||
fetchOptions(query = {}) {
|
||||
const params = this.ajaxOptions;
|
||||
const { url, resource, where = {}, text, depends } = params;
|
||||
params.where = Object.assign({}, where, query);
|
||||
if (this.q) {
|
||||
params.q = this.q;
|
||||
}
|
||||
const apiUrl = url
|
||||
? _.template(url)({ item: this.parent })
|
||||
: resource + "/options";
|
||||
this.$http.get(apiUrl, { params }).then(({ data }) => {
|
||||
this.model = { value: null };
|
||||
this.newOptions = data;
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.ajaxOptions) {
|
||||
const { url, resource, where, depends } = this.ajaxOptions;
|
||||
if (depends) {
|
||||
this.$watch(
|
||||
`parent.${depends}`,
|
||||
val => {
|
||||
// console.log(val);
|
||||
this.fetchOptions({ [depends]: val });
|
||||
},
|
||||
{
|
||||
// deep: true,
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.fetchOptions();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
74
front/src/components/FormUploader.vue
Normal file
74
front/src/components/FormUploader.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div>
|
||||
|
||||
<div class="preview">
|
||||
<div class="thumbnail" v-if="field.multiple">
|
||||
<b-draggable v-model="model" @input="update" class="row">
|
||||
<b-form-uploader-item class="col-4" :name="name" :id="`${id}_${k}`" :key="k"
|
||||
v-for="(v, k) in model" :field="field" :parent="parent" v-model="model[k]"
|
||||
@input="update"
|
||||
@remove="model.splice(k, 1) ; update()"
|
||||
@add="model.splice(k + 1,0, null) ; update()" allow-add />
|
||||
</b-draggable>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="row">
|
||||
<b-form-uploader-item class="col-12" :id="id" v-model="model" @open-file-browser="openFileBrowser(name)"
|
||||
@input="update" :name="name" :field="field" :parent="parent" @remove="model = null; update()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import BDraggable from "vuedraggable";
|
||||
import BFormUploaderItem from "./FormUploaderItem";
|
||||
import types from "../store/types";
|
||||
export default {
|
||||
components: {
|
||||
BDraggable,
|
||||
BFormUploaderItem
|
||||
},
|
||||
props: {
|
||||
value: {},
|
||||
field: {},
|
||||
id: {},
|
||||
parent: {},
|
||||
name: {}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
model: this.value || [],
|
||||
file: null,
|
||||
current: this.value
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.model = val;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
tag() {
|
||||
return this.field.type == "image" ? "img" : this.field.type;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openFileBrowser(name) {
|
||||
this.$store.commit(types.SHOW_FILE_BROWSER, { name });
|
||||
},
|
||||
update() {
|
||||
this.$emit("input", this.model);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.current {
|
||||
border: 1px solid #c30;
|
||||
}
|
||||
</style>
|
||||
|
||||
173
front/src/components/FormUploaderItem.vue
Normal file
173
front/src/components/FormUploaderItem.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-card>
|
||||
<b-data-value class="text-center" :field="field" :name="name" :model="parent"/>
|
||||
<div
|
||||
class="text-center text-muted mt-2"
|
||||
v-if="field.limit && field.limit.width"
|
||||
>{{$t('messages.image_size', field.limit)}}</div>
|
||||
<div slot="footer" class="text-center">
|
||||
<b-form-file
|
||||
ref="file"
|
||||
:id="fileName"
|
||||
:name="name"
|
||||
v-model="file"
|
||||
v-bind="field"
|
||||
@input="upload"
|
||||
class="d-none"
|
||||
:multiple="false"
|
||||
/>
|
||||
<b-form-input
|
||||
:value="String(file || '')"
|
||||
@input="$emit('input', arguments[0])"
|
||||
v-if="field.showInput"
|
||||
class="mb-2"
|
||||
></b-form-input>
|
||||
<b-btn
|
||||
v-if="field.showBrowse"
|
||||
@click="$emit('open-file-browser')"
|
||||
class="mr-2"
|
||||
size="sm"
|
||||
>{{$t('actions.file_browser')}}</b-btn>
|
||||
<label
|
||||
:for="`file_${id}`"
|
||||
class="btn btn-primary m-0 mr-2 btn-sm"
|
||||
>{{file ? $t('actions.change') : $t('actions.choose')}}</label>
|
||||
<b-btn @click="$emit('remove')" class="mr-2" size="sm">{{$t('actions.delete')}}</b-btn>
|
||||
<b-btn @click="$emit('add')" v-if="allowAdd" class="mr-2" size="sm">{{$t('actions.add')}}</b-btn>
|
||||
<b-btn
|
||||
v-if="field.showCopy"
|
||||
ref="copy_btn"
|
||||
:data-clipboard-text="value"
|
||||
class="mr-2"
|
||||
size="sm"
|
||||
>{{$t('actions.copy')}}</b-btn>
|
||||
</div>
|
||||
</b-card>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
// import ClipboardJS from 'clipboard'
|
||||
import BDataValue from "./DataValue";
|
||||
import Vue from "vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BDataValue
|
||||
},
|
||||
props: {
|
||||
value: {},
|
||||
field: {},
|
||||
id: {},
|
||||
parent: {},
|
||||
name: {},
|
||||
allowAdd: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
model: this.value,
|
||||
file: this.value,
|
||||
current: this.value,
|
||||
fileData: {}
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.file = val;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
text() {
|
||||
return String(this.value)
|
||||
.split("/")
|
||||
.pop();
|
||||
},
|
||||
modalName() {
|
||||
return `modal_${this.id}`;
|
||||
},
|
||||
fileName() {
|
||||
return `file_${this.id}`;
|
||||
},
|
||||
tag() {
|
||||
const tags = {
|
||||
image: "img",
|
||||
audio: "audio",
|
||||
video: "video"
|
||||
};
|
||||
return tags[this.field.type] ? tags[this.field.type] : "div";
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
Vue.nextTick(() => {
|
||||
// new ClipboardJS(this.$refs.copy_btn)
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
reset(error) {
|
||||
if (error) {
|
||||
this.$snotify.error(error);
|
||||
}
|
||||
this.model = this.oldValue;
|
||||
return false;
|
||||
},
|
||||
upload() {
|
||||
if (!this.file) {
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append("type", this.name);
|
||||
fd.append("file", this.file);
|
||||
this.fileData = this.file;
|
||||
|
||||
const src = URL.createObjectURL(this.file);
|
||||
|
||||
const doUpload = () => {
|
||||
this.$http.post("upload", fd).then(({ data }) => {
|
||||
this.file = data.url;
|
||||
this.$emit("input", this.file);
|
||||
this.$snotify.success(this.$t("messages.uploaded"));
|
||||
});
|
||||
};
|
||||
|
||||
const { width, height, size } = this.field.limit || {};
|
||||
|
||||
if (this.file.size > size) {
|
||||
return this.reset(
|
||||
this.$t("errors.too_large", { limit: parseInt(size / 1024) })
|
||||
);
|
||||
}
|
||||
|
||||
if (this.file.type.match(/^image/)) {
|
||||
let file = new Image();
|
||||
file.src = src;
|
||||
file.onload = () => {
|
||||
if (this.field.limit) {
|
||||
if (file.naturalHeight != height || file.naturalWidth != width) {
|
||||
return this.reset(
|
||||
this.$t("errors.wrong_size", { width, height })
|
||||
);
|
||||
}
|
||||
}
|
||||
doUpload();
|
||||
};
|
||||
} else {
|
||||
doUpload();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.current {
|
||||
border: 1px solid #c30;
|
||||
}
|
||||
.item {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
83
front/src/components/Header.vue
Normal file
83
front/src/components/Header.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<header class="app-header navbar">
|
||||
<button class="navbar-toggler mobile-sidebar-toggler d-lg-none" type="button" @click="mobileSidebarToggle">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<b-link class="navbar-brand" to="/">
|
||||
|
||||
</b-link>
|
||||
<button class="navbar-toggler sidebar-toggler d-md-down-none" type="button" @click="sidebarToggle">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<b-navbar-nav is-nav-bar class="d-md-down-none">
|
||||
<b-nav-item class="px-3">Dashboard</b-nav-item>
|
||||
<b-nav-item class="px-3">Users</b-nav-item>
|
||||
<b-nav-item class="px-3">Settings</b-nav-item>
|
||||
</b-navbar-nav>
|
||||
<b-navbar-nav is-nav-bar class="ml-auto">
|
||||
<b-nav-item class="d-md-down-none">
|
||||
<i class="icon-bell"></i>
|
||||
<b-badge pill variant="danger">5</b-badge>
|
||||
</b-nav-item>
|
||||
<b-nav-item class="d-md-down-none">
|
||||
<i class="icon-list"></i>
|
||||
</b-nav-item>
|
||||
<b-nav-item class="d-md-down-none">
|
||||
<i class="icon-location-pin"></i>
|
||||
</b-nav-item>
|
||||
<b-nav-item-dropdown right no-caret>
|
||||
<template slot="button-content">
|
||||
<img src="static/img/avatars/6.jpg" class="img-avatar" alt="admin@bootstrapmaster.com">
|
||||
</template>
|
||||
<b-dropdown-header tag="div" class="text-center"><strong>Account</strong></b-dropdown-header>
|
||||
<b-dropdown-item><i class="fa fa-bell-o"></i> Updates<b-badge variant="info">{{itemsCount}}</b-badge></b-dropdown-item>
|
||||
<b-dropdown-item><i class="fa fa-envelope-o"></i> Messages<b-badge variant="success">{{itemsCount}}</b-badge></b-dropdown-item>
|
||||
<b-dropdown-item><i class="fa fa-tasks"></i> Tasks<b-badge variant="danger">{{itemsCount}}</b-badge></b-dropdown-item>
|
||||
<b-dropdown-item><i class="fa fa-comments"></i> Comments<b-badge variant="warning">{{itemsCount}}</b-badge></b-dropdown-item>
|
||||
<b-dropdown-header tag="div" class="text-center"><strong>Settings</strong></b-dropdown-header>
|
||||
<b-dropdown-item><i class="fa fa-user"></i> Profile</b-dropdown-item>
|
||||
<b-dropdown-item><i class="fa fa-wrench"></i> Settings</b-dropdown-item>
|
||||
<b-dropdown-item><i class="fa fa-usd"></i> Payments<b-badge variant="default">{{itemsCount}}</b-badge></b-dropdown-item>
|
||||
<b-dropdown-item><i class="fa fa-file"></i> Projects<b-badge variant="primary">{{itemsCount}}</b-badge></b-dropdown-item>
|
||||
<b-dropdown-divider></b-dropdown-divider>
|
||||
<b-dropdown-item><i class="fa fa-shield"></i> Lock Account</b-dropdown-item>
|
||||
<b-dropdown-item><i class="fa fa-lock"></i> Logout</b-dropdown-item>
|
||||
</b-nav-item-dropdown>
|
||||
</b-navbar-nav>
|
||||
<button class="navbar-toggler aside-menu-toggler d-md-down-none" type="button" @click="asideToggle">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'header',
|
||||
components: {
|
||||
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
itemsCount: 3
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
sidebarToggle (e) {
|
||||
e.preventDefault()
|
||||
document.body.classList.toggle('sidebar-hidden')
|
||||
},
|
||||
sidebarMinimize (e) {
|
||||
e.preventDefault()
|
||||
document.body.classList.toggle('sidebar-minimized')
|
||||
},
|
||||
mobileSidebarToggle (e) {
|
||||
e.preventDefault()
|
||||
document.body.classList.toggle('sidebar-mobile-show')
|
||||
},
|
||||
asideToggle (e) {
|
||||
e.preventDefault()
|
||||
document.body.classList.toggle('aside-menu-hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
30
front/src/components/JsonEditor.vue
Normal file
30
front/src/components/JsonEditor.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div ref="editor"></div>
|
||||
</template>
|
||||
<script>
|
||||
import 'jsoneditor/dist/jsoneditor.min.css'
|
||||
import JSONEditor from "jsoneditor";
|
||||
export default {
|
||||
name: "b-json-editor",
|
||||
props: {
|
||||
value: null,
|
||||
options: {}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editor: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.editor = new JSONEditor(this.$refs.editor, this.options);
|
||||
if (typeof this.value === "string") {
|
||||
this.value = JSON.parse(this.value);
|
||||
}
|
||||
this.editor.set(this.value || {});
|
||||
this.editor.onChange = v => {
|
||||
this.$emit("input", JSON.stringify(v));
|
||||
};
|
||||
},
|
||||
created() {}
|
||||
};
|
||||
</script>
|
||||
32
front/src/components/LocaleSwitcher.vue
Normal file
32
front/src/components/LocaleSwitcher.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="py-2" v-if="site.locale_switcher">
|
||||
<b-button
|
||||
size="sm"
|
||||
class="mr-1"
|
||||
@click="changeLocale(lang)"
|
||||
:key="lang"
|
||||
v-for="lang in Object.keys($i18n.messages)"
|
||||
>{{lang | ucfirst}}</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import types from "../store/types";
|
||||
import { mapState } from "vuex";
|
||||
export default {
|
||||
computed: {
|
||||
...mapState(["site"])
|
||||
},
|
||||
filters: {
|
||||
ucfirst(val) {
|
||||
return String(val)[0].toUpperCase() + String(val).slice(1);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
changeLocale(locale) {
|
||||
this.$store.commit(types.SET_LOCALE, locale);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
457
front/src/components/NavBar.vue
Normal file
457
front/src/components/NavBar.vue
Normal file
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<header class="app-header navbar">
|
||||
<button display="md" mobile="true" type="button" class="d-lg-none navbar-toggler">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<a href="#/dashboard#" class="navbar-brand" target="_self">
|
||||
<img
|
||||
src="img/brand/logo.svg"
|
||||
width="89"
|
||||
height="25"
|
||||
alt="CoreUI Logo"
|
||||
class="navbar-brand-full"
|
||||
>
|
||||
<img
|
||||
src="img/brand/sygnet.svg"
|
||||
width="30"
|
||||
height="30"
|
||||
alt="CoreUI Logo"
|
||||
class="navbar-brand-minimized"
|
||||
>
|
||||
</a>
|
||||
<button display="lg" type="button" class="d-md-down-none navbar-toggler">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<ul class="navbar-nav d-md-down-none">
|
||||
<li class="nav-item px-3">
|
||||
<a href="#/dashboard" class="nav-link active" target="_self">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item px-3">
|
||||
<a href="#/users" class="nav-link" target="_self">Users</a>
|
||||
</li>
|
||||
<li class="nav-item px-3">
|
||||
<a href="#" target="_self" class="nav-link">Settings</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav ml-auto">
|
||||
<li class="nav-item d-md-down-none">
|
||||
<a href="#" target="_self" class="nav-link">
|
||||
<li id="__BVID__11" class="nav-item b-nav-dropdown dropdown">
|
||||
<a
|
||||
href="#"
|
||||
id="__BVID__11__BV_button_"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
class="nav-link"
|
||||
>
|
||||
<i class="icon-bell"></i>
|
||||
<span class="badge badge-danger badge-pill">5</span>
|
||||
</a>
|
||||
<div aria-labelledby="__BVID__11__BV_button_" class="dropdown-menu dropdown-menu-right">
|
||||
<div class="dropdown-header dropdown-menu-lg text-center">
|
||||
<strong>You have 5 notifications</strong>
|
||||
</div>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="icon-user-follow text-success"></i> New user registered
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="icon-user-unfollow text-danger"></i> User deleted
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="icon-chart text-info"></i> Sales report is ready
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="icon-basket-loaded text-primary"></i> New client
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="icon-speedometer text-warning"></i> Server overloaded
|
||||
</a>
|
||||
<div class="dropdown-header text-center">
|
||||
<strong>Server</strong>
|
||||
</div>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="text-uppercase mb-1">
|
||||
<small>
|
||||
<b>CPU Usage</b>
|
||||
</small>
|
||||
</div>
|
||||
<div class="progress-xs progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="25"
|
||||
class="progress-bar bg-info"
|
||||
style="width: 25%;"
|
||||
>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">348 Processes. 1/4 Cores.</small>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="text-uppercase mb-1">
|
||||
<small>
|
||||
<b>Memory Usage</b>
|
||||
</small>
|
||||
</div>
|
||||
<div class="progress-xs progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="70"
|
||||
class="progress-bar bg-warning"
|
||||
style="width: 70%;"
|
||||
>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">11444GB/16384MB</small>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="text-uppercase mb-1">
|
||||
<small>
|
||||
<b>SSD 1 Usage</b>
|
||||
</small>
|
||||
</div>
|
||||
<div class="progress-xs progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="90"
|
||||
class="progress-bar bg-danger"
|
||||
style="width: 90%;"
|
||||
>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">243GB/256GB</small>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item d-md-down-none">
|
||||
<a href="#" target="_self" class="nav-link">
|
||||
<li id="__BVID__20" class="nav-item b-nav-dropdown dropdown">
|
||||
<a
|
||||
href="#"
|
||||
id="__BVID__20__BV_button_"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
class="nav-link"
|
||||
>
|
||||
<i class="icon-list"></i>
|
||||
<span class="badge badge-warning badge-pill">15</span>
|
||||
</a>
|
||||
<div aria-labelledby="__BVID__20__BV_button_" class="dropdown-menu dropdown-menu-right">
|
||||
<div class="dropdown-header dropdown-menu-lg text-center">
|
||||
<strong>You have 15 pending tasks</strong>
|
||||
</div>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="small mb-1">Upgrade NPM & Bower
|
||||
<span class="float-right">
|
||||
<strong>0%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-xs progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="0"
|
||||
class="progress-bar bg-info"
|
||||
style="width: 0%;"
|
||||
>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="small mb-1">ReactJS Version
|
||||
<span class="float-right">
|
||||
<strong>25%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-xs progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="25"
|
||||
class="progress-bar bg-danger"
|
||||
style="width: 25%;"
|
||||
>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="small mb-1">VueJS Version
|
||||
<span class="float-right">
|
||||
<strong>50%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-xs progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="50"
|
||||
class="progress-bar bg-warning"
|
||||
style="width: 50%;"
|
||||
>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="small mb-1">Add new layouts
|
||||
<span class="float-right">
|
||||
<strong>75%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-xs progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="75"
|
||||
class="progress-bar bg-info"
|
||||
style="width: 75%;"
|
||||
>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="small mb-1">Angular 2 Cli Version
|
||||
<span class="float-right">
|
||||
<strong>100%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-xs progress">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow="100"
|
||||
class="progress-bar bg-success"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item text-center">
|
||||
<strong>View all tasks</strong>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item d-md-down-none">
|
||||
<a href="#" target="_self" class="nav-link">
|
||||
<li id="__BVID__33" class="nav-item b-nav-dropdown dropdown">
|
||||
<a
|
||||
href="#"
|
||||
id="__BVID__33__BV_button_"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
class="nav-link"
|
||||
>
|
||||
<i class="icon-envelope-letter"></i>
|
||||
<span class="badge badge-info badge-pill">7</span>
|
||||
</a>
|
||||
<div aria-labelledby="__BVID__33__BV_button_" class="dropdown-menu dropdown-menu-right">
|
||||
<div class="dropdown-menu-lg">
|
||||
<div class="dropdown-header text-center">
|
||||
<strong>You have 7 messages</strong>
|
||||
</div>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="message">
|
||||
<div class="pt-3 mr-3 float-left">
|
||||
<div class="avatar">
|
||||
<img
|
||||
src="img/avatars/7.jpg"
|
||||
alt="admin@bootstrapmaster.com"
|
||||
class="img-avatar"
|
||||
>
|
||||
<span class="avatar-status bg-success"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted">John Doe</small>
|
||||
<small class="text-muted float-right mt-1">Just now</small>
|
||||
</div>
|
||||
<div class="text-truncate font-weight-bold">
|
||||
<span class="fa fa-exclamation text-danger"></span> Important message
|
||||
</div>
|
||||
<div
|
||||
class="small text-muted text-truncate"
|
||||
>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt...</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="message">
|
||||
<div class="pt-3 mr-3 float-left">
|
||||
<div class="avatar">
|
||||
<img
|
||||
src="img/avatars/6.jpg"
|
||||
alt="admin@bootstrapmaster.com"
|
||||
class="img-avatar"
|
||||
>
|
||||
<span class="avatar-status bg-warning"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted">Jane Doe</small>
|
||||
<small class="text-muted float-right mt-1">5 minutes ago</small>
|
||||
</div>
|
||||
<div class="text-truncate font-weight-bold">Lorem ipsum dolor sit amet</div>
|
||||
<div
|
||||
class="small text-muted text-truncate"
|
||||
>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt...</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="message">
|
||||
<div class="pt-3 mr-3 float-left">
|
||||
<div class="avatar">
|
||||
<img
|
||||
src="img/avatars/5.jpg"
|
||||
alt="admin@bootstrapmaster.com"
|
||||
class="img-avatar"
|
||||
>
|
||||
<span class="avatar-status bg-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted">Janet Doe</small>
|
||||
<small class="text-muted float-right mt-1">1:52 PM</small>
|
||||
</div>
|
||||
<div class="text-truncate font-weight-bold">Lorem ipsum dolor sit amet</div>
|
||||
<div
|
||||
class="small text-muted text-truncate"
|
||||
>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt...</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<div class="message">
|
||||
<div class="pt-3 mr-3 float-left">
|
||||
<div class="avatar">
|
||||
<img
|
||||
src="img/avatars/4.jpg"
|
||||
alt="admin@bootstrapmaster.com"
|
||||
class="img-avatar"
|
||||
>
|
||||
<span class="avatar-status bg-info"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted">Joe Doe</small>
|
||||
<small class="text-muted float-right mt-1">4:03 AM</small>
|
||||
</div>
|
||||
<div class="text-truncate font-weight-bold">Lorem ipsum dolor sit amet</div>
|
||||
<div
|
||||
class="small text-muted text-truncate"
|
||||
>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt...</div>
|
||||
</div>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item text-center">
|
||||
<strong>View all messages</strong>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item d-md-down-none">
|
||||
<a href="#" target="_self" class="nav-link">
|
||||
<li id="__BVID__36" class="nav-item b-nav-dropdown dropdown">
|
||||
<a
|
||||
href="#"
|
||||
id="__BVID__36__BV_button_"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
class="nav-link"
|
||||
>
|
||||
<i class="icon-location-pin"></i>
|
||||
</a>
|
||||
<div aria-labelledby="__BVID__36__BV_button_" class="dropdown-menu dropdown-menu-right">
|
||||
<div>
|
||||
<h6 class="text-capitalize text-center">your dropdown</h6>
|
||||
<ol>
|
||||
<li>one</li>
|
||||
<li>two</li>
|
||||
<li>three</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</a>
|
||||
</li>
|
||||
<li id="__BVID__39" class="nav-item b-nav-dropdown dropdown">
|
||||
<a
|
||||
href="#"
|
||||
id="__BVID__39__BV_button_"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
class="nav-link"
|
||||
>
|
||||
<img src="img/avatars/6.jpg" alt="admin@bootstrapmaster.com" class="img-avatar">
|
||||
</a>
|
||||
<div aria-labelledby="__BVID__39__BV_button_" class="dropdown-menu dropdown-menu-right">
|
||||
<div class="dropdown-header text-center">
|
||||
<strong>Account</strong>
|
||||
</div>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-bell-o"></i> Updates
|
||||
<span class="badge badge-info">42</span>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-envelope-o"></i> Messages
|
||||
<span class="badge badge-success">42</span>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-tasks"></i> Tasks
|
||||
<span class="badge badge-danger">42</span>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-comments"></i> Comments
|
||||
<span class="badge badge-warning">42</span>
|
||||
</a>
|
||||
<div class="dropdown-header text-center">
|
||||
<strong>Settings</strong>
|
||||
</div>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-user"></i> Profile
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-wrench"></i> Settings
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-usd"></i> Payments
|
||||
<span class="badge badge-secondary">42</span>
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-file"></i> Projects
|
||||
<span class="badge badge-primary">42</span>
|
||||
</a>
|
||||
<div role="separator" class="dropdown-divider"></div>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-shield"></i> Lock Account
|
||||
</a>
|
||||
<a role="menuitem" href="#" target="_self" class="dropdown-item">
|
||||
<i class="fa fa-lock"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button display type="button" class="d-none d-lg-block navbar-toggler">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
97
front/src/components/Sidebar.1.vue
Normal file
97
front/src/components/Sidebar.1.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<b-aside class="sidebar position-fixed">
|
||||
<div class="text-center top">
|
||||
<a :href="site.url" target="_blank" v-if="site.logo">
|
||||
<img class="site-logo img-fluid" :src="site.logo">
|
||||
</a>
|
||||
<!-- <b-img class="site-logo" :src="require('../assets/img/gengyi-logo.svg')" fluid style="border-radius: 5px;" /> -->
|
||||
<!-- <b-img rounded="circle" :src="auth.user.avatar" height="70" blank-color="#777" alt="avatar" class="m-2" /> -->
|
||||
<div class="my-3" v-if="site.sidebar_userinfo !== false">
|
||||
<h5 style="letter-spacing:2px">{{site.name}}</h5>
|
||||
<template v-if="auth.user">
|
||||
<b-badge class="text-uppercase mr-1" v-if="auth.user.badge">{{auth.user.badge}}</b-badge>
|
||||
<span>{{auth.user.username}}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
|
||||
<!-- <locale-switcher></locale-switcher> -->
|
||||
<!-- <theme-switcher></theme-switcher> -->
|
||||
</div>
|
||||
<el-menu router>
|
||||
<template v-for="(item, index) in site.menu" >
|
||||
<el-submenu :index="item.url" :key="index" v-if="item.children">
|
||||
<template slot="title">
|
||||
<i :class="item.icon"></i>
|
||||
<span>{{item.name}}</span>
|
||||
</template>
|
||||
</el-submenu>
|
||||
<el-menu-item :index="item.url" :key="index" v-else>
|
||||
<i :class="item.icon"></i>
|
||||
<span class="">{{item.name}}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</b-aside>
|
||||
</template>
|
||||
<script>
|
||||
import ThemeSwitcher from "./ThemeSwitcher";
|
||||
import LocaleSwitcher from "./LocaleSwitcher";
|
||||
|
||||
import { mapState } from "vuex";
|
||||
export default {
|
||||
name: "sidebar",
|
||||
|
||||
computed: {
|
||||
...mapState(["auth", "site"])
|
||||
},
|
||||
components: { LocaleSwitcher, ThemeSwitcher },
|
||||
methods: {
|
||||
toggle(item) {
|
||||
this.$set(item, "open", !item.open);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// .sidebar {
|
||||
// z-index: 999;
|
||||
// box-shadow: 1px 0 20px rgba(0, 0, 0, 0.1);
|
||||
// width: 200px;
|
||||
// height: 100vh;
|
||||
// overflow: auto;
|
||||
// letter-spacing: 1px;
|
||||
// padding: 1rem;
|
||||
|
||||
// .site-logo {
|
||||
// // border-radius: 1rem;
|
||||
// // min-height:3em;
|
||||
// }
|
||||
|
||||
// .nav-link {
|
||||
// color: #666;
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// padding: 0.7rem 1rem;
|
||||
// // border-radius: 2rem;
|
||||
// // font-weight: 400;
|
||||
|
||||
// &:hover,
|
||||
// &.active {
|
||||
// // color: #333;
|
||||
// // background: #eee;
|
||||
// }
|
||||
// i {
|
||||
// margin-right: 1rem;
|
||||
// }
|
||||
// }
|
||||
// .nav-title {
|
||||
// font-size: 0.7rem;
|
||||
// font-weight: bold;
|
||||
// text-transform: uppercase;
|
||||
// color: #ced4da;
|
||||
// padding: 1rem 1rem 0.5rem 0;
|
||||
// }
|
||||
// }
|
||||
</style>
|
||||
107
front/src/components/Sidebar.vue
Normal file
107
front/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="pt-3 pl-3">
|
||||
<div class="text-center top">
|
||||
<a :href="site.url" target="_blank" v-if="site.logo" v-show="!collapsed">
|
||||
<b-img class="site-logo" :src="site.logo" fluid/>
|
||||
</a>
|
||||
|
||||
<!-- <b-img class="site-logo" :src="require('../assets/img/gengyi-logo.svg')" fluid style="border-radius: 5px;" /> -->
|
||||
<!-- <b-img rounded="circle" :src="auth.user.avatar" height="70" blank-color="#777" alt="avatar" class="m-2" /> -->
|
||||
<div class="my-3" v-if="site.sidebar_userinfo !== false" v-show="!collapsed">
|
||||
<h5 style="letter-spacing:2px">{{site.name}}</h5>
|
||||
<template v-if="auth.user">
|
||||
<b-badge class="text-uppercase mr-1" v-if="auth.user.badge">{{auth.user.badge}}</b-badge>
|
||||
<span>{{auth.user.username}}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
|
||||
<locale-switcher v-show="!collapsed"></locale-switcher>
|
||||
<theme-switcher v-show="!collapsed"></theme-switcher>
|
||||
</div>
|
||||
<div slot="header"></div>
|
||||
<b-nav pills class="sidebar-nav" vertical>
|
||||
<template v-for="(item, index) in site.menu">
|
||||
<b-nav-text v-if="item.title" :key="index">
|
||||
<small class="text-muted">
|
||||
<b>{{item.name}}</b>
|
||||
</small>
|
||||
</b-nav-text>
|
||||
<b-nav vertical v-else-if="item.children" :key="index">
|
||||
<b-nav-item :to="child.url" :key="child.name" v-for="child in item.children">
|
||||
<i class="mr-1" :class="child.icon"></i>
|
||||
<span>{{child.name}}</span>
|
||||
<b-badge v-bind="child.badge" v-if="child.badge">{{child.badge.text}}</b-badge>
|
||||
</b-nav-item>
|
||||
</b-nav>
|
||||
<b-nav-item :active="$route.path === item.url" :to="item.url" v-else :key="index">
|
||||
<i :class="{'mr-2': !collapsed, [item.icon]: true}"></i>
|
||||
<span v-show="!collapsed">{{item.name}}</span>
|
||||
<b-badge v-bind="item.badge" v-if="item.badge">{{item.badge.text}}</b-badge>
|
||||
</b-nav-item>
|
||||
</template>
|
||||
<slot></slot>
|
||||
</b-nav>
|
||||
<p></p>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import ThemeSwitcher from "./ThemeSwitcher";
|
||||
import LocaleSwitcher from "./LocaleSwitcher";
|
||||
|
||||
import { mapState } from "vuex";
|
||||
export default {
|
||||
name: "sidebar",
|
||||
props: {
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(["auth", "site"]),
|
||||
menu() {
|
||||
const menu = [];
|
||||
const titleIndices = [];
|
||||
this.site.menu.forEach((v, k) => {
|
||||
v.title && titleIndices.push(parseInt(k));
|
||||
});
|
||||
for (let i in titleIndices) {
|
||||
menu.push({
|
||||
name: this.site.menu[titleIndices[i]].name,
|
||||
children: this.site.menu.slice(
|
||||
titleIndices[i] + 1,
|
||||
titleIndices[parseInt(i) + 1]
|
||||
)
|
||||
});
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
},
|
||||
components: { LocaleSwitcher, ThemeSwitcher },
|
||||
methods: {
|
||||
toggle(item) {
|
||||
this.$set(item, "open", !item.open);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.site-logo {
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.sidebar-nav {
|
||||
.nav-item a {
|
||||
color: #666;
|
||||
padding: 0.7rem 1rem;
|
||||
}
|
||||
}
|
||||
.sidebar-nav .nav-item:hover {
|
||||
background: #f6f6f6;
|
||||
}
|
||||
</style>
|
||||
94
front/src/components/Switch.vue
Normal file
94
front/src/components/Switch.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<label :class="classList">
|
||||
<input type="checkbox"
|
||||
class="switch-input"
|
||||
:value="value"
|
||||
:checked="isChecked"
|
||||
@change="handleChange">
|
||||
<template v-if="isOn">
|
||||
<span class="switch-label" :data-on="on" :data-off="off"></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="switch-label"></span>
|
||||
</template>
|
||||
<span class="switch-handle"></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
model: {
|
||||
prop: 'checked',
|
||||
event: 'change'
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
default: true
|
||||
},
|
||||
uncheckedValue: {
|
||||
default: false
|
||||
},
|
||||
checked: {
|
||||
default: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
pill: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
on: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
off: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
classList () {
|
||||
return [
|
||||
'switch',
|
||||
this.switchType,
|
||||
this.switchVariant,
|
||||
this.switchPill,
|
||||
this.switchSize
|
||||
]
|
||||
},
|
||||
switchType () {
|
||||
return this.type ? `switch-${this.type}` : `switch-default`
|
||||
},
|
||||
switchVariant () {
|
||||
return this.variant ? `switch-${this.variant}` : `switch-secondary`
|
||||
},
|
||||
switchPill () {
|
||||
return !this.pill ? null : `switch-pill`
|
||||
},
|
||||
switchSize () {
|
||||
return this.size ? `switch-${this.size}` : ''
|
||||
},
|
||||
isChecked () {
|
||||
return this.checked === this.value
|
||||
},
|
||||
isOn () {
|
||||
return !this.on ? null : true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleChange ({ target: { checked } }) {
|
||||
this.$emit('change', checked ? this.value : this.uncheckedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
32
front/src/components/TableBuilder.vue
Normal file
32
front/src/components/TableBuilder.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<b-table class="table-builder" :fields="fields" :items="items">
|
||||
<template v-for="(field, key) in _.omit(fields, '_actions')" :slot="`HEAD_${key}`">
|
||||
<div :key="key" :class="{'text-right': ['number'].includes(field.type)}">
|
||||
{{field.label || key}}
|
||||
</div>
|
||||
</template>
|
||||
<template v-for="(field, key) in fields" :slot="key" slot-scope="row">
|
||||
<b-data-value :field="field" :key="key" :lang="currentLanguage" :name="key" :model="row.item" short-id />
|
||||
</template>
|
||||
|
||||
</b-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BDataValue from "./DataValue";
|
||||
import { mapState, mapGetters } from "vuex";
|
||||
export default {
|
||||
components: {
|
||||
BDataValue
|
||||
},
|
||||
props: {
|
||||
fields: {},
|
||||
items: Array
|
||||
},
|
||||
computed: {
|
||||
...mapState(["site", "i18n"]),
|
||||
...mapGetters(["currentMenu", "currentLanguage"]),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
35
front/src/components/ThemeSwitcher.vue
Normal file
35
front/src/components/ThemeSwitcher.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="m-4" v-if="$store.state.site.theme_switcher">
|
||||
<b-select :options="themeOptions" v-model="theme"></b-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import types from "../store/types.js";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
theme: {
|
||||
get() {
|
||||
return this.$store.state.site.theme;
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit(types.SET_THEME, val);
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
themeOptions: `
|
||||
cerulean darkly litera materia sandstone slate superhero
|
||||
cosmo flatly lumen minty simplex solar united
|
||||
cyborg journal lux pulse sketchy spacelab yeti
|
||||
`.split(/\s+/)
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.theme = this.$storage.get("theme") || 'cosmo';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user