@@ -19,8 +19,8 @@ | |||||
</RouterLink> | </RouterLink> | ||||
</div> | </div> | ||||
<div v-else> | <div v-else> | ||||
<RouterLink to="/addfund" class="button is-primary"> | |||||
Add Fund | |||||
<RouterLink to="/admin/dashboard" class="button is-primary"> | |||||
Admin | |||||
</RouterLink> | </RouterLink> | ||||
<RouterLink to="/register" class="button is-white"> | <RouterLink to="/register" class="button is-white"> | ||||
Register | Register | ||||
@@ -7,6 +7,7 @@ import { | |||||
CreateQueueRequest, | CreateQueueRequest, | ||||
CreateQueueResponse, | CreateQueueResponse, | ||||
CreateRewardFundRequest, | CreateRewardFundRequest, | ||||
EditQueueRequest, | |||||
GetBalanceRequest, | GetBalanceRequest, | ||||
GetBalanceResponse, | GetBalanceResponse, | ||||
GetContributionsRequest, | GetContributionsRequest, | ||||
@@ -19,6 +20,9 @@ import { | |||||
GetRewardFundsRequest, | GetRewardFundsRequest, | ||||
GetRewardFundsResponse, | GetRewardFundsResponse, | ||||
LoginResponse, | LoginResponse, | ||||
NearlyCompleteFundsRequest, | |||||
NearlyCompleteFundsResponse, | |||||
QueueMember, | |||||
SuccessResponse, | SuccessResponse, | ||||
} from '@/api/types'; | } from '@/api/types'; | ||||
@@ -49,8 +53,7 @@ export const createRewardFund = ( | |||||
issuerWallet: string, | issuerWallet: string, | ||||
memo: string, | memo: string, | ||||
minContribution: number, | minContribution: number, | ||||
title: string, | |||||
description: string, | |||||
telegramLink: string, | |||||
bonuses: Bonus[], | bonuses: Bonus[], | ||||
queueID?: number | null | undefined, | queueID?: number | null | undefined, | ||||
) => controller.post<SuccessResponse, CreateRewardFundRequest>('CreateRewardFund', { | ) => controller.post<SuccessResponse, CreateRewardFundRequest>('CreateRewardFund', { | ||||
@@ -60,8 +63,7 @@ export const createRewardFund = ( | |||||
issuerWallet, | issuerWallet, | ||||
memo, | memo, | ||||
minContribution, | minContribution, | ||||
title, | |||||
description, | |||||
telegramLink, | |||||
bonuses, | bonuses, | ||||
queueID, | queueID, | ||||
}); | }); | ||||
@@ -103,3 +105,10 @@ export const contribute = (privateKey: string, amount: number, rewardFund: numbe | |||||
amount, | amount, | ||||
rewardFund, | rewardFund, | ||||
}); | }); | ||||
export const getNearlyCompletedFunds = (threshold: number) => controller.post<NearlyCompleteFundsResponse, NearlyCompleteFundsRequest>('NearlyCompleteFunds', { threshold }); | |||||
export const reorderQueue = (queueID: number, fundOrders: QueueMember[]) => controller.post<SuccessResponse, EditQueueRequest>('EditQueue', { | |||||
queueID, | |||||
fundOrders, | |||||
}); |
@@ -28,8 +28,7 @@ export interface RewardFund { | |||||
amountGoal: number; | amountGoal: number; | ||||
minContribution: number; | minContribution: number; | ||||
contributions: Contribution[] | null; | contributions: Contribution[] | null; | ||||
title: string; | |||||
description: string; | |||||
telegramLink: string; | |||||
} | } | ||||
export interface Queue { | export interface Queue { | ||||
@@ -37,6 +36,12 @@ export interface Queue { | |||||
name: string; | name: string; | ||||
} | } | ||||
export interface QueueMember { | |||||
id?: number; | |||||
asset: string; | |||||
order: number; | |||||
} | |||||
export interface CreateQueueRequest { | export interface CreateQueueRequest { | ||||
name: string; | name: string; | ||||
} | } | ||||
@@ -70,8 +75,7 @@ export interface CreateRewardFundRequest { | |||||
issuerWallet: string; | issuerWallet: string; | ||||
memo: string; | memo: string; | ||||
minContribution: number; | minContribution: number; | ||||
title: string; | |||||
description: string; | |||||
telegramLink: string; | |||||
queueID?: number | null; | queueID?: number | null; | ||||
bonuses: Bonus[]; | bonuses: Bonus[]; | ||||
} | } | ||||
@@ -87,8 +91,7 @@ export interface FundInfo { | |||||
amountAvailable: number; | amountAvailable: number; | ||||
amountGoal: number; | amountGoal: number; | ||||
minContribution: number; | minContribution: number; | ||||
title: string; | |||||
description: string; | |||||
telegramLink: string; | |||||
bonuses: Bonus[]; | bonuses: Bonus[]; | ||||
queueID: number | null; | queueID: number | null; | ||||
} | } | ||||
@@ -149,6 +152,24 @@ export interface Claims { | |||||
exp: number; | exp: number; | ||||
} | } | ||||
export interface NearlyCompletedFund { | |||||
id: number; | |||||
asset: string; | |||||
minContribution: number; | |||||
amountAvailable: number; | |||||
memo: string; | |||||
fundWallet: string; | |||||
raised: number; | |||||
} | |||||
export interface NearlyCompleteFundsRequest { | |||||
threshold: number; | |||||
} | |||||
export interface NearlyCompleteFundsResponse { | |||||
funds: NearlyCompletedFund[]; | |||||
} | |||||
export interface GetContributionsRequest { | export interface GetContributionsRequest { | ||||
id: number; | id: number; | ||||
offset: number; | offset: number; | ||||
@@ -162,3 +183,8 @@ export interface CloseRewardFundRequest { | |||||
id: number; | id: number; | ||||
close: boolean; | close: boolean; | ||||
} | } | ||||
export interface EditQueueRequest { | |||||
queueID: number; | |||||
fundOrders: QueueMember[]; | |||||
} |
@@ -1,7 +1,18 @@ | |||||
<template> | <template> | ||||
<div class="is-flex is-flex-direction-row is-justify-content-space-between"> | |||||
<div class="select mr-1"> | |||||
<div | |||||
class="is-flex is-flex-direction-row is-justify-content-space-between" | |||||
:class="props.orientation === 'horizontal' | |||||
? 'is-flex-direction-row' | |||||
: 'is-flex-direction-column'" | |||||
> | |||||
<div class="select" | |||||
:class="props.orientation === 'horizontal' ? 'mr-1' : 'mr-1 mb-2'" | |||||
:style="props.orientation === 'vertical' | |||||
? 'width: 28%; min-width: 180px; align-self: end' | |||||
: undefined" | |||||
> | |||||
<select | <select | ||||
style="width: 100%" | |||||
ref="queueOptions" | ref="queueOptions" | ||||
v-model="queueSelection" | v-model="queueSelection" | ||||
aria-label="Queue Selection" | aria-label="Queue Selection" | ||||
@@ -14,6 +25,12 @@ | |||||
</option> | </option> | ||||
</select> | </select> | ||||
</div> | </div> | ||||
<div v-if="queueSelection === undefined" class="is-flex-grow-1"> | |||||
<div class="is-size-6 has-text-centered is-italic py-4"> | |||||
Select an option from the menu | |||||
{{ props.orientation === 'vertical' ? 'above' : 'to the left' }} | |||||
</div> | |||||
</div> | |||||
<div v-if="queueSelection === -1" class="is-flex is-flex-direction-row is-flex-grow-1 ml-1"> | <div v-if="queueSelection === -1" class="is-flex is-flex-direction-row is-flex-grow-1 ml-1"> | ||||
<input | <input | ||||
v-model="queueName" | v-model="queueName" | ||||
@@ -32,7 +49,7 @@ | |||||
:key="element.order" | :key="element.order" | ||||
> | > | ||||
<div class="is-flex is-flex-direction-row is-justify-content-space-between"> | <div class="is-flex is-flex-direction-row is-justify-content-space-between"> | ||||
<div>{{ element.title }} ({{ element.asset }})</div> | |||||
<div>{{ element.asset }}</div> | |||||
<div>{{ element.order }}</div> | <div>{{ element.order }}</div> | ||||
</div> | </div> | ||||
</div> | </div> | ||||
@@ -49,23 +66,22 @@ import { | |||||
} from 'vue'; | } from 'vue'; | ||||
import { | import { | ||||
Queue, | Queue, | ||||
QueueMember, | |||||
RewardFund, | RewardFund, | ||||
} from '@/api/types'; | } from '@/api/types'; | ||||
import { | import { | ||||
getQueueMembers, | getQueueMembers, | ||||
getQueues, | getQueues, | ||||
reorderQueue, | |||||
} from '@/api/composed'; | } from '@/api/composed'; | ||||
import { VueDraggableNext as Draggable } from 'vue-draggable-next'; | import { VueDraggableNext as Draggable } from 'vue-draggable-next'; | ||||
interface QueueMember { | |||||
id?: number; | |||||
title: string; | |||||
asset: string; | |||||
order: number; | |||||
} | |||||
import { useDebounceFn } from '@vueuse/core'; | |||||
// eslint-disable-next-line no-undef | // eslint-disable-next-line no-undef | ||||
const props = defineProps<{ newMember: RewardFund & { order: number; } }>(); | |||||
const props = defineProps<{ | |||||
newMember: RewardFund & { order: number; }, | |||||
orientation: 'horizontal' | 'vertical' | |||||
}>(); | |||||
// eslint-disable-next-line no-undef | // eslint-disable-next-line no-undef | ||||
const emits = defineEmits(['selected', 'created']); | const emits = defineEmits(['selected', 'created']); | ||||
@@ -75,13 +91,21 @@ const queueName = ref(undefined as string | undefined); | |||||
const queueMembers = ref(undefined as QueueMember[] | undefined); | const queueMembers = ref(undefined as QueueMember[] | undefined); | ||||
const serverQueues = ref(0); | const serverQueues = ref(0); | ||||
const reorder = () => { | |||||
const sendQueueOrder = useDebounceFn(async () => { | |||||
if (queueSelection.value && queueMembers.value) { | |||||
await reorderQueue(queueSelection.value, queueMembers.value); | |||||
} | |||||
}, 2000); | |||||
const reorder = async () => { | |||||
if (queueMembers.value) { | if (queueMembers.value) { | ||||
for (let i = 0; i < queueMembers.value.length; i += 1) { | for (let i = 0; i < queueMembers.value.length; i += 1) { | ||||
if (queueMembers.value[i]) { | if (queueMembers.value[i]) { | ||||
queueMembers.value[i].order = (i + 1); | queueMembers.value[i].order = (i + 1); | ||||
} | } | ||||
} | } | ||||
await sendQueueOrder(); | |||||
} | } | ||||
}; | }; | ||||
@@ -107,16 +131,14 @@ const populateQueueMembers = async (id: number) => { | |||||
const resp = await getQueueMembers(id) as { members: (RewardFund & { order: number; })[] }; | const resp = await getQueueMembers(id) as { members: (RewardFund & { order: number; })[] }; | ||||
queueMembers.value = resp?.members.map((m) => ({ | queueMembers.value = resp?.members.map((m) => ({ | ||||
id: m.id, | id: m.id, | ||||
title: m.title, | |||||
asset: m.asset, | asset: m.asset, | ||||
order: m.order, | order: m.order, | ||||
})); | })); | ||||
serverQueues.value = queueMembers.value.length; | serverQueues.value = queueMembers.value.length; | ||||
if (queueMembers.value && props.newMember.title && props.newMember.asset) { | |||||
if (queueMembers.value && props.newMember.asset) { | |||||
const newMember = { | const newMember = { | ||||
id: undefined, | id: undefined, | ||||
title: props.newMember.title, | |||||
asset: props.newMember.asset, | asset: props.newMember.asset, | ||||
order: queueMembers.value.length + 1, | order: queueMembers.value.length + 1, | ||||
}; | }; | ||||
@@ -134,10 +156,9 @@ watch(queueSelection, async (newValue) => { | |||||
const addedMember = computed(() => props.newMember); | const addedMember = computed(() => props.newMember); | ||||
watch(addedMember, (newVal) => { | watch(addedMember, (newVal) => { | ||||
if (newVal.title && newVal.asset) { | |||||
if (newVal.asset) { | |||||
const assembleNewMember = (order: number) => ({ | const assembleNewMember = (order: number) => ({ | ||||
id: undefined, | id: undefined, | ||||
title: newVal.title, | |||||
asset: newVal.asset, | asset: newVal.asset, | ||||
order, | order, | ||||
}); | }); | ||||
@@ -1,8 +1,19 @@ | |||||
<template> | <template> | ||||
<div class="card py-2 px-4 has-text-dark" | <div class="card py-2 px-4 has-text-dark" | ||||
:style="generateBackgroundStyle(`${fund.asset} ${fund.title}`)"> | :style="generateBackgroundStyle(`${fund.asset} ${fund.title}`)"> | ||||
<div class="is-size-2-desktop is-size-2-tablet is-size-3-mobile"> | |||||
{{ fund.asset }} | |||||
<div class="is-flex | |||||
is-flex-direction-row | |||||
is-justify-content-space-between | |||||
is-size-2-desktop | |||||
is-size-2-tablet | |||||
is-size-3-mobile" | |||||
> | |||||
<div> | |||||
{{ fund.asset }} | |||||
</div> | |||||
<div v-if="props.aside"> | |||||
{{ props.aside }} | |||||
</div> | |||||
</div> | </div> | ||||
<div> | <div> | ||||
<ul class="is-flex is-flex-direction-row is-justify-content-space-between"> | <ul class="is-flex is-flex-direction-row is-justify-content-space-between"> | ||||
@@ -30,15 +41,18 @@ | |||||
</template> | </template> | ||||
<script setup lang="ts"> | <script setup lang="ts"> | ||||
import { truncateWallet } from '@/lib/helpers'; | import { truncateWallet } from '@/lib/helpers'; | ||||
import { | |||||
FundInfo, | |||||
} from '@/api/types'; | |||||
import { FundInfo } from '@/api/types'; | |||||
import { PropType } from 'vue'; | import { PropType } from 'vue'; | ||||
// eslint-disable-next-line no-undef | // eslint-disable-next-line no-undef | ||||
const props = defineProps({ fund: Object as PropType<FundInfo> }); | |||||
const props = defineProps({ | |||||
fund: Object as PropType<FundInfo>, | |||||
aside: Object as PropType<string | number | undefined>, | |||||
}); | |||||
const generateHue = (seed: string) => seed.split('').map((c) => c.charCodeAt(0)).reduce((v1, v2) => v1 + v2) % 256; | |||||
const generateHue = (seed: string) => seed.split('') | |||||
.map((c) => c.charCodeAt(0)) | |||||
.reduce((v1, v2) => v1 + v2) % 256; | |||||
const generateBackgroundStyle = (hueSeed: string) => { | const generateBackgroundStyle = (hueSeed: string) => { | ||||
const hue = generateHue(hueSeed); | const hue = generateHue(hueSeed); | ||||
@@ -14,6 +14,9 @@ import FundView from '@/views/FundView.vue'; | |||||
import AddFundView from '@/views/AddFundView.vue'; | import AddFundView from '@/views/AddFundView.vue'; | ||||
import hasPermission from '@/lib/auth'; | import hasPermission from '@/lib/auth'; | ||||
import SignetRequestController from '@/api/requests'; | import SignetRequestController from '@/api/requests'; | ||||
import AdminView from '@/views/AdminView.vue'; | |||||
import ModifyQueueView from '@/views/ModifyQueueView.vue'; | |||||
import AdminDashboardView from '@/views/AdminDashboardView.vue'; | |||||
const routes: Array<RouteRecordRaw> = [ | const routes: Array<RouteRecordRaw> = [ | ||||
{ | { | ||||
@@ -49,12 +52,60 @@ const routes: Array<RouteRecordRaw> = [ | |||||
}, | }, | ||||
}, | }, | ||||
{ | { | ||||
path: '/addfund', | |||||
name: 'addfund', | |||||
component: AddFundView, | |||||
path: '/admin', | |||||
name: 'admin', | |||||
component: AdminView, | |||||
redirect: '/admin/dashboard', | |||||
children: [ | |||||
{ | |||||
path: 'dashboard', | |||||
name: 'admindashboard', | |||||
component: AdminDashboardView, | |||||
meta: { | |||||
requiredRights: Privileges.Admin, | |||||
title: 'Admin', | |||||
}, | |||||
}, | |||||
{ | |||||
path: 'addfund', | |||||
name: 'addfund', | |||||
component: AddFundView, | |||||
meta: { | |||||
requiredRights: Privileges.Admin, | |||||
title: 'Add Group Fund', | |||||
}, | |||||
}, | |||||
{ | |||||
path: 'modifyfund', | |||||
name: 'modifyfund', | |||||
component: AddFundView, | |||||
meta: { | |||||
requiredRights: Privileges.Admin, | |||||
title: 'Add Group Fund', | |||||
}, | |||||
}, | |||||
{ | |||||
path: 'addqueue', | |||||
name: 'addqueue', | |||||
component: AddFundView, | |||||
meta: { | |||||
requiredRights: Privileges.Admin, | |||||
title: 'Add Group Fund', | |||||
}, | |||||
}, | |||||
{ | |||||
path: 'modifyqueue', | |||||
name: 'modifyqueue', | |||||
component: ModifyQueueView, | |||||
meta: { | |||||
requiredRights: Privileges.Admin, | |||||
title: 'Add Group Fund', | |||||
}, | |||||
}, | |||||
], | |||||
meta: { | meta: { | ||||
requiredRights: Privileges.Admin, | requiredRights: Privileges.Admin, | ||||
title: 'Add Group Fund', | |||||
title: 'Administrator', | |||||
}, | }, | ||||
}, | }, | ||||
]; | ]; | ||||
@@ -49,6 +49,7 @@ | |||||
<div class="title is-5 has-text-white-ter">Queue</div> | <div class="title is-5 has-text-white-ter">Queue</div> | ||||
<EditQueue | <EditQueue | ||||
:new-member="constructFund()" | :new-member="constructFund()" | ||||
orientation="horizontal" | |||||
@created="setQueueName" | @created="setQueueName" | ||||
@selected="setQueueSelection" | @selected="setQueueSelection" | ||||
/> | /> | ||||
@@ -0,0 +1,39 @@ | |||||
<template> | |||||
<div class="container is-max-desktop"> | |||||
<section class="section is-small px-0"> | |||||
<template v-if="nearlyCompletedFunds.length > 0"> | |||||
<div class="title is-4 has-text-white-ter has-text-centered">Nearly Completed Funds</div> | |||||
<div v-for="(fund, ind) in nearlyCompletedFunds" :key="ind"> | |||||
<RouterLink :to="`/fund/${fund.id}`"> | |||||
<FundLink :fund="fund" :aside="`${round(fund.raised/fund.amountAvailable*100)}%`"/> | |||||
</RouterLink> | |||||
</div> | |||||
</template> | |||||
</section> | |||||
</div> | |||||
</template> | |||||
<script setup lang="ts"> | |||||
import { ref } from 'vue'; | |||||
import { getNearlyCompletedFunds } from '@/api/composed'; | |||||
import FundLink from '@/components/FundLink.vue'; | |||||
import { NearlyCompletedFund } from '@/api/types'; | |||||
const nearlyCompletedFunds = ref([] as NearlyCompletedFund[]); | |||||
const pctThreshold = 85; | |||||
const req = await getNearlyCompletedFunds(pctThreshold); | |||||
if (req?.funds) { | |||||
nearlyCompletedFunds.value = req?.funds; | |||||
} | |||||
const round = (float: number, digits = 1) => { | |||||
const factor = 10 ** digits; | |||||
return Math.round(float * factor) / factor; | |||||
}; | |||||
</script> | |||||
<style scoped lang="stylus"> | |||||
</style> |
@@ -1,15 +1,17 @@ | |||||
<template> | <template> | ||||
<!-- Unused --> | <!-- Unused --> | ||||
<div class="is-flex is-flex-direction-row"> | <div class="is-flex is-flex-direction-row"> | ||||
<nav class="is-hidden-tablet-only is-hidden-mobile has-background-white-ter"> | |||||
<div class="has-background-primary"> | |||||
<span class="has-text-white has-text-weight-bold my-4 mx-2">Administration</span> | |||||
<nav class="is-hidden-tablet-only is-hidden-mobile"> | |||||
<div class="mt-6"> | |||||
<div> | |||||
<span class="has-text-white has-text-weight-bold my-4 mx-2">Administration</span> | |||||
</div> | |||||
<ul class="p-2"> | |||||
<li v-for="(link, i) in links" v-bind:key="i"> | |||||
<RouterLink :to="link.to" class="has-text-grey-light">{{ link.text }}</RouterLink> | |||||
</li> | |||||
</ul> | |||||
</div> | </div> | ||||
<ul class="p-2"> | |||||
<li v-for="(link, i) in links" v-bind:key="i"> | |||||
<RouterLink :to="link.to">{{ link.text }}</RouterLink> | |||||
</li> | |||||
</ul> | |||||
</nav> | </nav> | ||||
<div class="container is-max-desktop"> | <div class="container is-max-desktop"> | ||||
<RouterView></RouterView> | <RouterView></RouterView> | ||||
@@ -19,16 +21,30 @@ | |||||
<script setup lang="ts"> | <script setup lang="ts"> | ||||
const links = [ | const links = [ | ||||
{ text: 'Add Fund', to: '/admin/addfund' }, | |||||
{ text: 'Modify Fund', to: '' }, | |||||
{ text: 'Create Tag', to: '' }, | |||||
{ | |||||
text: 'Dashboard', | |||||
to: '/admin/dashboard', | |||||
}, | |||||
{ | |||||
text: 'Add Fund', | |||||
to: '/admin/addfund', | |||||
}, | |||||
{ | |||||
text: 'Modify Fund', | |||||
to: '/admin/modifyfund', | |||||
}, | |||||
{ | |||||
text: 'Add/Modify Queue', | |||||
to: '/admin/modifyqueue', | |||||
}, | |||||
]; | ]; | ||||
</script> | </script> | ||||
<style scoped lang="stylus"> | <style scoped lang="stylus"> | ||||
nav | nav | ||||
min-width 134px | min-width 134px | ||||
min-height 100vh | |||||
height calc(100vh - 56px) | |||||
li | li | ||||
font-variant all-petite-caps | font-variant all-petite-caps | ||||
@@ -1,16 +1,19 @@ | |||||
<template> | <template> | ||||
<div class="container is-max-desktop pb-4"> | <div class="container is-max-desktop pb-4"> | ||||
<section class="section is-small"> | <section class="section is-small"> | ||||
<div class="title is-size-4 has-text-white-ter has-text-centered"> | |||||
{{ fund.fundInfo.title }} | |||||
</div> | |||||
<div | <div | ||||
class="is-block-mobile | class="is-block-mobile | ||||
is-flex-tablet-only | is-flex-tablet-only | ||||
is-flex-desktop | is-flex-desktop | ||||
is-flex-direction-row | is-flex-direction-row | ||||
is-justify-content-space-between"> | is-justify-content-space-between"> | ||||
<div class="fund-description pr-5" v-html="fixNewlines(fund.fundInfo.description)"> | |||||
<div class="my-auto"> | |||||
<div class="is-size-1">{{ fund.fundInfo.asset }}</div> | |||||
<div> | |||||
<a :href="fund.fundInfo.telegramLink" target="_blank" class="has-text-grey-light"> | |||||
{{ fund.fundInfo.telegramLink }} | |||||
</a> | |||||
</div> | |||||
</div> | </div> | ||||
<div | <div | ||||
class="fund-details is-flex is-flex-direction-row is-justify-content-end my-auto py-6"> | class="fund-details is-flex is-flex-direction-row is-justify-content-end my-auto py-6"> | ||||
@@ -375,7 +378,7 @@ const errs: SignetError[] = [ | |||||
}, | }, | ||||
]; | ]; | ||||
document.title = `Beignet - ${fund.value.fundInfo.title}`; | |||||
document.title = `Beignet - ${fund.value.fundInfo.asset}`; | |||||
watch(selectedDate, async (newVal) => { | watch(selectedDate, async (newVal) => { | ||||
offset.value = 0; | offset.value = 0; | ||||
@@ -0,0 +1,24 @@ | |||||
<template> | |||||
<div class="container is-max-desktop"> | |||||
<section class="section is-small"> | |||||
<div class="title is-4 has-text-white-ter has-text-centered"> | |||||
Add/Modify Queue | |||||
</div> | |||||
<section class="section px-0 py-4"> | |||||
<EditQueue :new-member="undefined" orientation="vertical" @created="createQueue"/> | |||||
</section> | |||||
</section> | |||||
</div> | |||||
</template> | |||||
<script setup lang="ts"> | |||||
import EditQueue from '@/components/EditQueue.vue'; | |||||
import { createQueue } from '@/api/composed'; | |||||
// | |||||
</script> | |||||
<style scoped lang="stylus"> | |||||
</style> |