initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Nuxt 3 Minimal Starter
|
||||
|
||||
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm run dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm run build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm run preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
6
app.config.ts
Normal file
6
app.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
primary: 'green',
|
||||
gray: 'neutral'
|
||||
}
|
||||
})
|
||||
7
app.vue
Normal file
7
app.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLayout>
|
||||
<NuxtPage/>
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
2
components/uni/Button/index.d.ts
vendored
Normal file
2
components/uni/Button/index.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
type ButtonType = 'normal' | 'primary' | 'danger'
|
||||
type ButtonSize = 'base' | 'medium' | 'small'
|
||||
123
components/uni/Button/index.vue
Normal file
123
components/uni/Button/index.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts" setup>
|
||||
const emit = defineEmits(['click'])
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String as PropType<ButtonType>,
|
||||
default: 'normal'
|
||||
},
|
||||
attrType: {
|
||||
type: String as PropType<'button' | 'submit' | 'reset'>,
|
||||
default: 'button'
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<ButtonSize>,
|
||||
default: 'base'
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const buttonTypeClass = computed(() => {
|
||||
let ret = `uni-button--normal`
|
||||
if (props.type !== 'normal') ret += ` uni-button--${props.type}`
|
||||
return ret
|
||||
})
|
||||
|
||||
const buttonSizeClass = computed(() => {
|
||||
return `uni-button--${props.size}`
|
||||
})
|
||||
|
||||
const buttonIcon = computed(() => {
|
||||
if (props.icon) return props.icon
|
||||
return null
|
||||
})
|
||||
|
||||
const handleClick = (e: any) => {
|
||||
emit('click', e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="w-fit flex justify-center items-center rounded-md font-bold border shadow-sm transition focus:ring-4"
|
||||
:class="{
|
||||
'w-full': block,
|
||||
'uni-button--disabled': disabled || loading,
|
||||
[buttonTypeClass]: buttonTypeClass,
|
||||
[buttonSizeClass]: buttonSizeClass,
|
||||
}" @click="handleClick" :disabled="disabled || loading" :type="attrType">
|
||||
<Transition name="icon">
|
||||
<UniIconSpinner v-if="loading" />
|
||||
<Icon v-else-if="buttonIcon" :name="buttonIcon" :key="buttonIcon" />
|
||||
<span v-else class="mr-2">
|
||||
<slot name="icon"/>
|
||||
</span>
|
||||
</Transition>
|
||||
<div class="flex items-center whitespace-nowrap leading-snug" :class="{ 'ml-2': buttonIcon || loading }">
|
||||
<slot />
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-enter-active,
|
||||
.icon-leave-active {
|
||||
transition: all .3s ease;
|
||||
}
|
||||
|
||||
.icon-enter-from,
|
||||
.icon-leave-to {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.uni-button--normal {
|
||||
@apply bg-neutral-50 hover:bg-neutral-100 active:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:active:bg-neutral-600;
|
||||
@apply ring-neutral-200/50 dark:ring-neutral-800/50;
|
||||
@apply border-neutral-300 dark:border-neutral-700;
|
||||
@apply text-neutral-700 dark:text-neutral-300;
|
||||
}
|
||||
|
||||
.uni-button--primary {
|
||||
@apply text-blue-600 dark:text-blue-600;
|
||||
}
|
||||
|
||||
.uni-button--danger {
|
||||
@apply text-red-500 dark:text-red-500;
|
||||
}
|
||||
|
||||
.uni-button--disabled {
|
||||
@apply bg-neutral-100 dark:bg-neutral-900 hover:bg-neutral-100 hover:dark:bg-neutral-900;
|
||||
@apply ring-transparent;
|
||||
@apply border-transparent;
|
||||
@apply text-neutral-400 dark:text-neutral-600;
|
||||
}
|
||||
|
||||
.uni-button--base {
|
||||
@apply text-base;
|
||||
@apply px-4 py-2;
|
||||
}
|
||||
|
||||
.uni-button--medium {
|
||||
@apply text-sm;
|
||||
@apply px-3 py-1.5;
|
||||
}
|
||||
|
||||
.uni-button--small {
|
||||
@apply text-sm;
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
</style>
|
||||
82
components/uni/Copyable/index.vue
Normal file
82
components/uni/Copyable/index.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import {useMessage} from "~/composables/uni/useMessage";
|
||||
|
||||
const props = defineProps({
|
||||
hideIcon: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
iconSize: {
|
||||
type: String,
|
||||
default: '1em'
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const copied = ref(false)
|
||||
const copied_timeout = ref()
|
||||
|
||||
const fuck_copy = () => {
|
||||
navigator.clipboard.writeText(props.text || '').then(() => {
|
||||
copied.value = true
|
||||
if (copied_timeout.value) clearInterval(copied_timeout.value)
|
||||
copied_timeout.value = setTimeout(() => copied.value = false, 1500)
|
||||
}).catch(e => {
|
||||
message.error(`复制失败`)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex items-center gap-0.5 cursor-pointer" @click="fuck_copy">
|
||||
<slot/>
|
||||
<Transition v-if="!hideIcon" name="icon" mode="out-in">
|
||||
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" :width="iconSize" :height="iconSize" viewBox="0 0 24 24"
|
||||
class="text-neutral-500">
|
||||
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
|
||||
<path d="M8 10a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2z"/>
|
||||
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" :width="iconSize" :height="iconSize" viewBox="0 0 24 24"
|
||||
class="text-green-600">
|
||||
<defs>
|
||||
<mask id="lineMdCheckAll0">
|
||||
<g fill="none" stroke="#fff" stroke-dasharray="22" stroke-dashoffset="22" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="2">
|
||||
<path d="M2 13.5l4 4l10.75 -10.75">
|
||||
<animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="22;0"/>
|
||||
</path>
|
||||
<path stroke="#000" stroke-width="4" d="M7.5 13.5l4 4l10.75 -10.75" opacity="0">
|
||||
<set attributeName="opacity" begin="0.2s" to="1"/>
|
||||
<animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="22;0"/>
|
||||
</path>
|
||||
<path d="M7.5 13.5l4 4l10.75 -10.75" opacity="0">
|
||||
<set attributeName="opacity" begin="0.2s" to="1"/>
|
||||
<animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="22;0"/>
|
||||
</path>
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect width="24" height="24" fill="currentColor" mask="url(#lineMdCheckAll0)"/>
|
||||
</svg>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-enter-active,
|
||||
.icon-leave-active {
|
||||
@apply transition duration-300;
|
||||
}
|
||||
|
||||
.icon-enter-from,
|
||||
.icon-leave-to {
|
||||
@apply opacity-0;
|
||||
}
|
||||
</style>
|
||||
10
components/uni/Icon/CircleError.vue
Normal file
10
components/uni/Icon/CircleError.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<g fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
|
||||
<path d="M0 0h24v24H0z"></path>
|
||||
<path fill="currentColor"
|
||||
d="M17 3.34a10 10 0 1 1-14.995 8.984L2 12l.005-.324A10 10 0 0 1 17 3.34zm-6.489 5.8a1 1 0 0 0-1.218 1.567L10.585 12l-1.292 1.293l-.083.094a1 1 0 0 0 1.497 1.32L12 13.415l1.293 1.292l.094.083a1 1 0 0 0 1.32-1.497L13.415 12l1.292-1.293l.083-.094a1 1 0 0 0-1.497-1.32L12 10.585l-1.293-1.292l-.094-.083z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
7
components/uni/Icon/CircleInfo.vue
Normal file
7
components/uni/Icon/CircleInfo.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" fillRule="evenodd"
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Zm-10 5.75a.75.75 0 0 0 .75-.75v-6a.75.75 0 0 0-1.5 0v6c0 .414.336.75.75.75ZM12 7a1 1 0 1 1 0 2a1 1 0 0 1 0-2Z"
|
||||
clipRule="evenodd"></path>
|
||||
</svg>
|
||||
</template>
|
||||
10
components/uni/Icon/CircleSuccess.vue
Normal file
10
components/uni/Icon/CircleSuccess.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<g fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
|
||||
<path d="M0 0h24v24H0z"></path>
|
||||
<path fill="currentColor"
|
||||
d="M17 3.34a10 10 0 1 1-14.995 8.984L2 12l.005-.324A10 10 0 0 1 17 3.34zm-1.293 5.953a1 1 0 0 0-1.32-.083l-.094.083L11 12.585l-1.293-1.292l-.094-.083a1 1 0 0 0-1.403 1.403l.083.094l2 2l.094.083a1 1 0 0 0 1.226 0l.094-.083l4-4l.083-.094a1 1 0 0 0-.083-1.32z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
10
components/uni/Icon/CircleWarning.vue
Normal file
10
components/uni/Icon/CircleWarning.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<g fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
|
||||
<path d="M0 0h24v24H0z"></path>
|
||||
<path fill="currentColor"
|
||||
d="M12 2c5.523 0 10 4.477 10 10a10 10 0 0 1-19.995.324L2 12l.004-.28C2.152 6.327 6.57 2 12 2zm.01 13l-.127.007a1 1 0 0 0 0 1.986L12 17l.127-.007a1 1 0 0 0 0-1.986L12.01 15zM12 7a1 1 0 0 0-.993.883L11 8v4l.007.117a1 1 0 0 0 1.986 0L13 12V8l-.007-.117A1 1 0 0 0 12 7z">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
11
components/uni/Icon/Spinner.vue
Normal file
11
components/uni/Icon/Spinner.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"></path>
|
||||
<path fill="currentColor"
|
||||
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z">
|
||||
<animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate"
|
||||
values="0 12 12;360 12 12"></animateTransform>
|
||||
</path>
|
||||
</svg>
|
||||
</template>
|
||||
90
components/uni/Input/index.vue
Normal file
90
components/uni/Input/index.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts" setup>
|
||||
import type {PropType} from "vue";
|
||||
|
||||
const emit = defineEmits(['input', 'change', 'update:modelValue'])
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number] as PropType<string | number | undefined>,
|
||||
required: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'text' | 'password' | 'number' | 'email' | 'tel' | 'date'>,
|
||||
required: false,
|
||||
default: 'text'
|
||||
},
|
||||
justify: {
|
||||
type: String as PropType<'start' | 'end'>,
|
||||
required: false,
|
||||
default: 'end'
|
||||
},
|
||||
pattern: {
|
||||
type: [String, RegExp],
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
})
|
||||
|
||||
const inputValue = ref(props.modelValue)
|
||||
const isError = ref(false)
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
inputValue.value = value
|
||||
if (props.pattern && value) {
|
||||
const pattern = typeof props.pattern === 'string' ? new RegExp(props.pattern) : props.pattern
|
||||
isError.value = !pattern.test(value as string)
|
||||
pattern.lastIndex = 0
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleInput = (e: any) => {
|
||||
if (props.disabled) return
|
||||
const value = e.target.value
|
||||
|
||||
if (props.pattern && value && props.type !== 'date') {
|
||||
const pattern = typeof props.pattern === 'string' ? new RegExp(props.pattern) : props.pattern
|
||||
isError.value = !pattern.test(value)
|
||||
pattern.lastIndex = 0
|
||||
inputValue.value = value
|
||||
if (isError.value) return
|
||||
}
|
||||
|
||||
inputValue.value = value
|
||||
isError.value = false
|
||||
|
||||
emit('update:modelValue', e.target.value)
|
||||
emit('input', e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1"
|
||||
:class="{ 'justify-start': justify === 'start', 'justify-end': justify === 'end' }">
|
||||
<p class="block w-fit text-neutral-700 dark:text-neutral-300 text-sm font-bold font-['Nunito']" v-if="label">
|
||||
{{ label }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<input class="relative w-full flex items-center gap-2.5 p-2 pr-2 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800
|
||||
border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700
|
||||
focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800
|
||||
outline-none placeholder-neutral-400 dark:placeholder-neutral-500 shadow-sm"
|
||||
:class="{ '!border-red-500': isError, 'bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600': disabled }"
|
||||
:value="inputValue" @input="handleInput" :placeholder="placeholder" :disabled="disabled" :type="type"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
98
components/uni/Message/Provider.vue
Normal file
98
components/uni/Message/Provider.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type {Message, MessageApi, MessageProviderApi, MessageType} from "~/components/uni/Message/index";
|
||||
|
||||
const props = defineProps({
|
||||
max: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
})
|
||||
|
||||
const nuxtApp = useNuxtApp()
|
||||
const messageList = ref<Message[]>([])
|
||||
|
||||
const createMessage = (content: string, type: MessageType, duration: number = 3000) => {
|
||||
const {max} = props
|
||||
messageList.value.push({
|
||||
id: (Date.now() + Math.random() * 100).toString(32).toUpperCase(),
|
||||
content,
|
||||
type,
|
||||
duration
|
||||
})
|
||||
if (messageList.value.length > max) {
|
||||
messageList.value.shift()
|
||||
}
|
||||
}
|
||||
|
||||
const providerApi: MessageProviderApi = {
|
||||
destroy: (id: string) => {
|
||||
messageList.value.splice(messageList.value.findIndex(message => message.id === id), 1)
|
||||
}
|
||||
}
|
||||
|
||||
const api: MessageApi = {
|
||||
info: (content: string, duration: number = 3000) => {
|
||||
createMessage(content, 'info', duration);
|
||||
},
|
||||
success: (content: string, duration: number = 3000) => {
|
||||
createMessage(content, 'success', duration);
|
||||
},
|
||||
warning: (content: string, duration: number = 3000) => {
|
||||
createMessage(content, 'warning', duration);
|
||||
},
|
||||
error: (content: string, duration: number = 3000) => {
|
||||
createMessage(content, 'error', duration);
|
||||
},
|
||||
destroyAll: function (): void {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
}
|
||||
|
||||
nuxtApp.vueApp.provide('uni-message-provider', providerApi)
|
||||
nuxtApp.vueApp.provide('uni-message', api)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot/>
|
||||
<teleport to="body">
|
||||
<div id="message-provider">
|
||||
<div class="message-wrapper">
|
||||
<TransitionGroup name="message">
|
||||
<UniMessage v-for="(message, k) in messageList" :key="message.id" :message="message"/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#message-provider .message-wrapper {
|
||||
@apply z-[50000] fixed inset-0 flex flex-col items-center pointer-events-none;
|
||||
}
|
||||
|
||||
.message-move,
|
||||
.message-leave-active {
|
||||
transition: all .6s ease;
|
||||
}
|
||||
|
||||
.message-enter-active {
|
||||
transition: all .6s cubic-bezier(0.075, 0.82, 0.165, 1);
|
||||
}
|
||||
|
||||
.message-enter-from {
|
||||
filter: blur(2px);
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.message-leave-to {
|
||||
filter: blur(6px);
|
||||
opacity: 0;
|
||||
transform: translateY(-20%);
|
||||
}
|
||||
|
||||
.message-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
20
components/uni/Message/index.d.ts
vendored
Normal file
20
components/uni/Message/index.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
export type Message = {
|
||||
id: string
|
||||
content: string
|
||||
type: MessageType
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type MessageType = 'success' | 'warning' | 'error' | 'info'
|
||||
|
||||
export type MessageProviderApi = {
|
||||
destroy: (id: string) => void
|
||||
}
|
||||
|
||||
export type MessageApi = {
|
||||
info: (content: string, duration?: number) => void
|
||||
success: (content: string, duration?: number) => void
|
||||
warning: (content: string, duration?: number) => void
|
||||
error: (content: string, duration?: number) => void
|
||||
destroyAll: () => void
|
||||
}
|
||||
63
components/uni/Message/index.vue
Normal file
63
components/uni/Message/index.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type {Message, MessageProviderApi} from "~/components/uni/Message/index";
|
||||
|
||||
const providerApi = inject<MessageProviderApi>('uni-message-provider')
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
require: true,
|
||||
type: Object
|
||||
}
|
||||
})
|
||||
|
||||
const message = ref<Message>(props.message as Message)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
providerApi?.destroy(message.value.id)
|
||||
}, message.value?.duration || 3000);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message" :class="{
|
||||
'!text-blue-500 !border-blue-400 !bg-blue-50': message.type === 'info',
|
||||
'!text-emerald-500 !border-emerald-400 !bg-emerald-50': message.type === 'success',
|
||||
'!text-orange-500 !border-orange-400 !bg-orange-50': message.type === 'warning',
|
||||
'!text-rose-500 !border-rose-400 !bg-rose-50': message.type === 'error',
|
||||
[message.type]: message.type
|
||||
}">
|
||||
<UniIconCircleSuccess v-if="message.type === 'success'" class="text-xl" />
|
||||
<UniIconCircleWarning v-if="message.type === 'warning'" class="text-xl" />
|
||||
<UniIconCircleError v-if="message.type === 'error'" class="text-xl" />
|
||||
<UniIconCircleInfo v-if="message.type === 'info'" class="text-xl" />
|
||||
<span>
|
||||
{{ message.content }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
min-width: 80px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
|
||||
@apply h-fit px-2 py-1.5 border bg-white border-gray-300 rounded-md text-gray-500 text-xs flex items-center gap-1.5 first-of-type:mt-2.5 mt-2.5 font-bold pointer-events-auto;
|
||||
}
|
||||
|
||||
.message.info {
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, .2);
|
||||
}
|
||||
|
||||
.message.success {
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, .2);
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
box-shadow: 0 4px 12px rgba(249, 115, 22, .2);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
box-shadow: 0 4px 12px rgba(244, 63, 94, .2);
|
||||
}
|
||||
</style>
|
||||
6
components/uni/Select/index.d.ts
vendored
Normal file
6
components/uni/Select/index.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
type SelectItem = {
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
129
components/uni/Select/index.vue
Normal file
129
components/uni/Select/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, type PropType} from "vue";
|
||||
|
||||
const emit = defineEmits(['input', 'change', 'update:modelValue'])
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: false
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<SelectItem[]>,
|
||||
required: true
|
||||
},
|
||||
justify: {
|
||||
type: String as PropType<'start' | 'end'>,
|
||||
required: false,
|
||||
default: 'end'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
align: {
|
||||
type: String as PropType<'bottom' | 'top'>,
|
||||
required: false,
|
||||
default: 'bottom'
|
||||
}
|
||||
})
|
||||
|
||||
const selectWrapperRef = ref()
|
||||
const selectRef = ref()
|
||||
const optionsRef = ref()
|
||||
|
||||
const optionsAlign = computed(() => {
|
||||
switch (props.align) {
|
||||
case "bottom":
|
||||
return 'top-full mt-2'
|
||||
case "top":
|
||||
return 'bottom-full mb-2'
|
||||
}
|
||||
})
|
||||
const hasAnyIcon = computed(() => props.items.some(item => item.icon))
|
||||
const selectedItem = computed(() => props.items.find(item => item.value === props.modelValue) as SelectItem)
|
||||
const optionsExpanded = ref(false)
|
||||
const selectedIconFlag = ref(true)
|
||||
|
||||
const handleSelectClick = () => {
|
||||
optionsExpanded.value = !optionsExpanded.value
|
||||
}
|
||||
const handleOptionSelect = (option: SelectItem) => {
|
||||
emit('input', option.value)
|
||||
emit('change', option.value)
|
||||
emit('update:modelValue', option.value)
|
||||
selectedIconFlag.value = false;
|
||||
nextTick(() => {
|
||||
selectedIconFlag.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
selectRef.value.ownerDocument.addEventListener('click', (e: { target: any; }) => {
|
||||
if (optionsExpanded && !selectRef?.value?.contains(e.target)) {
|
||||
optionsExpanded.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1"
|
||||
:class="{ 'justify-start': justify === 'start', 'justify-end': justify === 'end' }">
|
||||
<p class="block w-fit text-neutral-700 dark:text-neutral-300 text-sm font-bold font-['Nunito']" v-if="label">
|
||||
{{ label }}
|
||||
</p>
|
||||
<div class="relative" ref="selectWrapperRef">
|
||||
<button class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden border transition bg-white dark:bg-neutral-800
|
||||
border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700
|
||||
focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800 shadow-sm"
|
||||
:class="{ 'cursor-not-allowed bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600': disabled }" ref="selectRef" type="button" @click="handleSelectClick" :disabled="disabled">
|
||||
<span v-if="selectedItem?.icon && !selectedIconFlag && hasAnyIcon"
|
||||
class="inline-block w-5 h-5 pointer-events-none"></span>
|
||||
<Icon v-else-if="selectedItem?.icon && selectedIconFlag && hasAnyIcon" :name="selectedItem?.icon"
|
||||
class="inline-block w-5 h-5 pointer-events-none" />
|
||||
<Transition name="select-item" mode="out-in">
|
||||
<span class="leading-snug whitespace-nowrap text-sm" :key="selectedItem?.value">{{
|
||||
selectedItem?.label || selectedItem?.value || 'Select an option'
|
||||
}}</span>
|
||||
</Transition>
|
||||
<Icon name="tabler:dots-vertical"
|
||||
class="absolute bg-neutral-50 text-gray-500 dark:bg-neutral-700/50 dark:text-neutral-500 inset-y-0 right-0 h-full" />
|
||||
</button>
|
||||
<div class="absolute right-0 w-full md:w-fit rounded-md border overflow-x-hidden overflow-y-auto transition shadow-lg opacity-0
|
||||
bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-800 z-50 max-h-64"
|
||||
:class="{ 'opacity-100 pointer-events-auto': optionsExpanded, '-translate-y-4 pointer-events-none': !optionsExpanded, [optionsAlign]: optionsAlign }"
|
||||
ref="optionsRef">
|
||||
<div class="flex items-center gap-2.5 px-2 py-2 cursor-pointer
|
||||
dark:text-neutral-300 font-['Nunito'] transition whitespace-nowrap
|
||||
bg-white dark:bg-neutral-800 hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
v-for="(option, index) in items" :key="index" :class="{
|
||||
'!bg-neutral-200 dark:!bg-neutral-700 hover:!bg-neutral-200 dark:hover:!bg-neutral-700': option.value === selectedItem?.value,
|
||||
'!cursor-not-allowed text-neutral-300 dark:text-neutral-500 hover:bg-white dark:hover:!bg-neutral-800': option.disabled
|
||||
}" @click="!option.disabled ? handleOptionSelect(option) : void 0">
|
||||
<div class="inline-block w-5 h-5" v-if="hasAnyIcon && !option.icon"></div>
|
||||
<Icon :name="(option?.icon)" class="inline-block w-5 h-5" v-if="option.icon" />
|
||||
<span class="leading-none whitespace-nowrap text-sm font-sans">{{ option.label || 'No label' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select-item-enter-active,
|
||||
.select-item-leave-active {
|
||||
transition: all .15s ease;
|
||||
}
|
||||
|
||||
.select-item-enter-from,
|
||||
.select-item-leave-to {
|
||||
opacity: .5;
|
||||
filter: blur(2px);
|
||||
}
|
||||
</style>
|
||||
112
components/uni/TextArea/index.vue
Normal file
112
components/uni/TextArea/index.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
|
||||
import { textarea } from '@nuxt/ui';
|
||||
<script lang="ts" setup>
|
||||
const emit = defineEmits(['input', 'change', 'update:modelValue'])
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
justify: {
|
||||
type: String as PropType<'start' | 'end'>,
|
||||
required: false,
|
||||
default: 'end'
|
||||
},
|
||||
pattern: {
|
||||
type: [String, RegExp],
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 5
|
||||
},
|
||||
minRows: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const textAreaRef = ref()
|
||||
const inputValue = ref(props.modelValue)
|
||||
const isError = ref(false)
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
inputValue.value = value
|
||||
if (props.pattern && value) {
|
||||
const pattern = typeof props.pattern === 'string' ? new RegExp(props.pattern) : props.pattern
|
||||
isError.value = !pattern.test(value as string)
|
||||
pattern.lastIndex = 0
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleInput = (e: any) => {
|
||||
if (props.disabled) return
|
||||
const value = e.target.value
|
||||
|
||||
if (props.pattern && value) {
|
||||
const pattern = typeof props.pattern === 'string' ? new RegExp(props.pattern) : props.pattern
|
||||
isError.value = !pattern.test(value)
|
||||
pattern.lastIndex = 0
|
||||
inputValue.value = value
|
||||
if (isError.value) return
|
||||
}
|
||||
|
||||
inputValue.value = value
|
||||
isError.value = false
|
||||
|
||||
emit('update:modelValue', e.target.value)
|
||||
emit('input', e)
|
||||
}
|
||||
|
||||
const autosize = (e: any) => {
|
||||
const el = e?.target ? e.target : e
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.minRows) {
|
||||
const textarea = textAreaRef.value
|
||||
textarea?.addEventListener('keydown', autosize)
|
||||
textarea?.addEventListener('input', autosize)
|
||||
textarea?.addEventListener('focus', autosize)
|
||||
autosize(textarea)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1"
|
||||
:class="{ 'justify-start': justify === 'start', 'justify-end': justify === 'end' }">
|
||||
<p class="block w-fit text-neutral-700 dark:text-neutral-300 text-sm font-bold font-['Nunito']" v-if="label">
|
||||
{{ label }}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<textarea class="relative w-full flex items-center gap-2.5 p-2 pr-6 rounded-md overflow-hidden overflow-y-auto border transition bg-white dark:bg-neutral-800
|
||||
border-neutral-200 dark:border-neutral-800 focus:border-neutral-400 dark:focus:border-neutral-700
|
||||
focus:ring-4 focus:ring-opacity-50 focus:ring-neutral-200 dark:focus:ring-neutral-800
|
||||
outline-none placeholder-neutral-400 dark:placeholder-neutral-500 shadow-sm"
|
||||
:rows="minRows || rows" ref="textAreaRef"
|
||||
:class="{ '!border-red-500': isError, 'bg-neutral-100 dark:bg-neutral-900 text-neutral-400 dark:text-neutral-600': disabled }"
|
||||
:value="inputValue" @input="handleInput" :placeholder="placeholder" :disabled="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
133
components/uni/Toggle/index.vue
Normal file
133
components/uni/Toggle/index.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from "vue";
|
||||
|
||||
const emit = defineEmits(['input', 'change', 'update:modelValue'])
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<'sm' | 'md' | 'lg'>,
|
||||
required: false,
|
||||
default: 'md'
|
||||
},
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
onIcon: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
offIcon: {
|
||||
type: String,
|
||||
required: false,
|
||||
}
|
||||
})
|
||||
|
||||
const checked = ref(false)
|
||||
|
||||
const buttonSizeClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'h-6 w-10'
|
||||
case 'md':
|
||||
return 'h-8 w-14'
|
||||
case 'lg':
|
||||
return 'h-10 w-[calc(2.5rem/0.54)]'
|
||||
}
|
||||
})
|
||||
|
||||
const buttonPaddingClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'p-1'
|
||||
case 'md':
|
||||
return 'p-1'
|
||||
case 'lg':
|
||||
return 'p-1.5'
|
||||
}
|
||||
})
|
||||
|
||||
const bulletSizeClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'h-4'
|
||||
case 'md':
|
||||
return 'h-6'
|
||||
case 'lg':
|
||||
return 'h-7'
|
||||
}
|
||||
})
|
||||
|
||||
const bulletTranslateClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'translate-x-4'
|
||||
case 'md':
|
||||
return 'translate-x-6'
|
||||
case 'lg':
|
||||
return 'translate-x-8'
|
||||
}
|
||||
})
|
||||
|
||||
const handleCheck = () => {
|
||||
checked.value = !checked.value
|
||||
emit('update:modelValue', checked.value)
|
||||
emit('change', checked.value)
|
||||
emit('input', checked.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue) {
|
||||
checked.value = props.modelValue
|
||||
} else if (props.value) {
|
||||
checked.value = props.value
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
checked.value = value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="relative flex items-center rounded-lg bg-neutral-100 dark:bg-neutral-800 shadow-inner transition ease-in-out group outline-none"
|
||||
:class="{
|
||||
'!bg-green-400 dark:!bg-green-400/50': checked,
|
||||
[buttonSizeClass]: buttonSizeClass,
|
||||
[buttonPaddingClass]: buttonPaddingClass
|
||||
}" @click="handleCheck">
|
||||
<span
|
||||
class="aspect-[1/1] translate-x-0 transition ease-in-out bg-white dark:bg-black rounded-md shadow duration-300 group-active:scale-90"
|
||||
:class="{
|
||||
'!shadow-lg': checked,
|
||||
'group-active:translate-x-3 group-active:duration-500': !checked,
|
||||
[bulletSizeClass]: bulletSizeClass,
|
||||
[bulletTranslateClass]: checked
|
||||
}">
|
||||
<span v-if="onIcon || offIcon" class="absolute inset-0 flex items-center justify-center text-neutral-400">
|
||||
<Transition name="icon" mode="out-in">
|
||||
<slot v-if="checked" name="on-icon"/>
|
||||
<slot v-else name="off-icon"/>
|
||||
</Transition>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.icon-enter-active,
|
||||
.icon-leave-active {
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.icon-enter-from,
|
||||
.icon-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
</style>
|
||||
13
layouts/default.vue
Normal file
13
layouts/default.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
11
nuxt.config.ts
Normal file
11
nuxt.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: true },
|
||||
modules: [
|
||||
'@nuxt/ui',
|
||||
'radix-vue/nuxt'
|
||||
],
|
||||
colorMode: {
|
||||
preference: 'dark'
|
||||
}
|
||||
})
|
||||
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^2.14.1",
|
||||
"nuxt": "^3.10.3",
|
||||
"radix-vue": "^1.4.9",
|
||||
"vue": "^3.4.19",
|
||||
"vue-router": "^4.3.0"
|
||||
}
|
||||
}
|
||||
11
pages/index.vue
Normal file
11
pages/index.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
index
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
3
server/tsconfig.json
Normal file
3
server/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
13
tailwind.config.ts
Normal file
13
tailwind.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default <Partial<Config>>{
|
||||
theme: {
|
||||
extend: {
|
||||
aspectRatio: {
|
||||
auto: 'auto',
|
||||
square: '1 / 1',
|
||||
video: '16 / 9'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user