🎨 Style(custom): lint code

This commit is contained in:
Kuingsmile
2025-08-15 13:29:09 +08:00
parent 0ae27cfeef
commit f11a4264d0
160 changed files with 18208 additions and 20414 deletions

View File

@@ -1,115 +1,107 @@
<template>
<div class="image-container">
<div
v-if="isLoading"
class="loading-placeholder"
>
<div class="loading-spinner" />
</div>
<img
v-else-if="!hasError"
:src="
isShowThumbnail && item.isImage
? base64Image
: `./assets/icons/${getFileIconPath(item.fileName ?? '')}`
"
alt=""
class="image"
@load="handleImageLoad"
@error="handleImageError"
>
<img
v-else
:src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`"
alt=""
class="image"
>
</div>
</template>
<script lang="ts" setup>
import { onBeforeMount, ref } from 'vue'
import { getFileIconPath } from '@/manage/utils/common'
const base64Image = ref('')
const isLoading = ref(true)
const hasError = ref(false)
const props = defineProps<{
isShowThumbnail: boolean
item: {
isImage: boolean
fileName: string
}
localPath: string
}>()
const createBase64Image = async () => {
try {
const filePath = window.node.path.normalize(props.localPath)
const base64 = await window.node.fs.readFile(filePath, 'base64')
base64Image.value = `data:${window.node.mime.lookup(filePath) || 'image/png'};base64,${base64}`
isLoading.value = false
} catch (e) {
console.log(e)
hasError.value = true
isLoading.value = false
}
}
const handleImageLoad = () => {
isLoading.value = false
hasError.value = false
}
const handleImageError = () => {
isLoading.value = false
hasError.value = true
}
onBeforeMount(async () => {
await createBase64Image()
})
</script>
<style scoped>
.image-container {
height: 100px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.image {
max-height: 100%;
max-width: 100%;
object-fit: contain;
display: block;
}
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<template>
<div class="image-container">
<div v-if="isLoading" class="loading-placeholder">
<div class="loading-spinner" />
</div>
<img
v-else-if="!hasError"
:src="isShowThumbnail && item.isImage ? base64Image : `./assets/icons/${getFileIconPath(item.fileName ?? '')}`"
alt=""
class="image"
@load="handleImageLoad"
@error="handleImageError"
/>
<img v-else :src="`./assets/icons/${getFileIconPath(item.fileName ?? '')}`" alt="" class="image" />
</div>
</template>
<script lang="ts" setup>
import { onBeforeMount, ref } from 'vue'
import { getFileIconPath } from '@/manage/utils/common'
const base64Image = ref('')
const isLoading = ref(true)
const hasError = ref(false)
const props = defineProps<{
isShowThumbnail: boolean
item: {
isImage: boolean
fileName: string
}
localPath: string
}>()
const createBase64Image = async () => {
try {
const filePath = window.node.path.normalize(props.localPath)
const base64 = await window.node.fs.readFile(filePath, 'base64')
base64Image.value = `data:${window.node.mime.lookup(filePath) || 'image/png'};base64,${base64}`
isLoading.value = false
} catch (e) {
console.log(e)
hasError.value = true
isLoading.value = false
}
}
const handleImageLoad = () => {
isLoading.value = false
hasError.value = false
}
const handleImageError = () => {
isLoading.value = false
hasError.value = true
}
onBeforeMount(async () => {
await createBase64Image()
})
</script>
<style scoped>
.image-container {
height: 100px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.image {
max-height: 100%;
max-width: 100%;
object-fit: contain;
display: block;
}
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,123 +1,123 @@
<template>
<div class="image-container">
<div
v-if="isLoading"
class="loading-placeholder"
>
<div class="loading-spinner" />
</div>
<img
v-else-if="!hasError"
:src="imageSource"
alt=""
class="image"
@load="handleImageLoad"
@error="handleImageError"
>
<img
v-else
:src="iconPath"
alt=""
class="image"
>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { getFileIconPath } from '@/manage/utils/common'
import { IRPCActionType } from '@/utils/enum'
const preSignedUrl = ref('')
const isLoading = ref(true)
const hasError = ref(false)
const props = defineProps<{
item: {
key: string
isImage: boolean
fileName: string | null | undefined
}
alias: string
url: string
config: any
isShowThumbnail: boolean
}>()
const imageSource = computed(() => {
return props.isShowThumbnail && props.item.isImage
? preSignedUrl.value
: `./assets/icons/${getFileIconPath(props.item.fileName ?? '')}`
})
const iconPath = computed(() => `./assets/icons/${getFileIconPath(props.item.fileName ?? '')}`)
async function getUrl () {
try {
isLoading.value = true
hasError.value = false
preSignedUrl.value = await window.electron.triggerRPC<any>(IRPCActionType.MANAGE_GET_PRE_SIGNED_URL, props.alias, props.config)
isLoading.value = false
} catch (error) {
console.error('Failed to get pre-signed URL:', error)
hasError.value = true
isLoading.value = false
}
}
const handleImageLoad = () => {
isLoading.value = false
hasError.value = false
}
const handleImageError = () => {
isLoading.value = false
hasError.value = true
}
watch(() => [props.url, props.item], getUrl, { deep: true })
onMounted(getUrl)
</script>
<style scoped>
.image-container {
height: 100px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.image {
max-height: 100%;
max-width: 100%;
object-fit: contain;
display: block;
}
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<template>
<div class="image-container">
<div v-if="isLoading" class="loading-placeholder">
<div class="loading-spinner" />
</div>
<img
v-else-if="!hasError"
:src="imageSource"
alt=""
class="image"
@load="handleImageLoad"
@error="handleImageError"
/>
<img v-else :src="iconPath" alt="" class="image" />
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { getFileIconPath } from '@/manage/utils/common'
import { IRPCActionType } from '@/utils/enum'
const preSignedUrl = ref('')
const isLoading = ref(true)
const hasError = ref(false)
const props = defineProps<{
item: {
key: string
isImage: boolean
fileName: string | null | undefined
}
alias: string
url: string
config: any
isShowThumbnail: boolean
}>()
const imageSource = computed(() => {
return props.isShowThumbnail && props.item.isImage
? preSignedUrl.value
: `./assets/icons/${getFileIconPath(props.item.fileName ?? '')}`
})
const iconPath = computed(() => `./assets/icons/${getFileIconPath(props.item.fileName ?? '')}`)
async function getUrl() {
try {
isLoading.value = true
hasError.value = false
preSignedUrl.value = await window.electron.triggerRPC<any>(
IRPCActionType.MANAGE_GET_PRE_SIGNED_URL,
props.alias,
props.config
)
isLoading.value = false
} catch (error) {
console.error('Failed to get pre-signed URL:', error)
hasError.value = true
isLoading.value = false
}
}
const handleImageLoad = () => {
isLoading.value = false
hasError.value = false
}
const handleImageError = () => {
isLoading.value = false
hasError.value = true
}
watch(() => [props.url, props.item], getUrl, { deep: true })
onMounted(getUrl)
</script>
<style scoped>
.image-container {
height: 100px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.image {
max-height: 100%;
max-width: 100%;
object-fit: contain;
display: block;
}
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,150 @@
<template>
<div class="image-container">
<div
v-if="isLoading"
class="loading-placeholder"
>
<div class="loading-spinner" />
</div>
<img
v-else-if="!hasError"
:src="imageSource"
alt=""
class="image"
@load="handleImageLoad"
@error="handleImageError"
>
<img
v-else
:src="iconPath"
alt=""
class="image"
>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { getFileIconPath } from '@/manage/utils/common'
import { getAuthHeader } from '@/manage/utils/digestAuth'
import { formatEndpoint } from '@/utils/common'
const base64Url = ref('')
const success = ref(false)
const isLoading = ref(true)
const hasError = ref(false)
const props = defineProps<{
item: {
key: string
isImage: boolean
fileName: string | null | undefined
}
url: string
config: any
isShowThumbnail: boolean
}>()
const imageSource = computed(() => {
return props.isShowThumbnail && props.item.isImage && success.value
? base64Url.value
: `./assets/icons/${getFileIconPath(props.item.fileName ?? '')}`
})
const iconPath = computed(() => `./assets/icons/${getFileIconPath(props.item.fileName ?? '')}`)
async function getWebdavHeader (key: string) {
let headers = {} as any
if (props.config.authType === 'digest') {
const authHeader = await getAuthHeader(
'GET',
formatEndpoint(props.config.endpoint, props.config.sslEnabled || false),
`/${key.replace(/^\//, '')}`,
props.config.username,
props.config.password
)
headers = {
Authorization: authHeader
}
} else {
headers = {
Authorization: 'Basic ' + Buffer.from(`${props.config.username}:${props.config.password}`).toString('base64')
}
}
return headers
}
const fetchImage = async () => {
try {
isLoading.value = true
hasError.value = false
const headers = await getWebdavHeader(props.item.key)
const res = await fetch(props.url, { method: 'GET', headers })
if (res.status >= 200 && res.status < 300) {
const blob = await res.blob()
success.value = true
base64Url.value = URL.createObjectURL(blob)
isLoading.value = false
} else {
throw new Error('Network response was not ok.')
}
} catch (err) {
success.value = false
hasError.value = true
isLoading.value = false
console.log(err)
}
}
const handleImageLoad = () => {
isLoading.value = false
hasError.value = false
}
const handleImageError = () => {
isLoading.value = false
hasError.value = true
}
watch(() => [props.url, props.item], fetchImage, { deep: true })
onMounted(fetchImage)
</script>
<style scoped>
.image-container {
height: 100px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.image {
max-height: 100%;
max-width: 100%;
object-fit: contain;
display: block;
}
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<template>
<div class="image-container">
<div v-if="isLoading" class="loading-placeholder">
<div class="loading-spinner" />
</div>
<img
v-else-if="!hasError"
:src="imageSource"
alt=""
class="image"
@load="handleImageLoad"
@error="handleImageError"
/>
<img v-else :src="iconPath" alt="" class="image" />
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'
import { getFileIconPath } from '@/manage/utils/common'
import { getAuthHeader } from '@/manage/utils/digestAuth'
import { formatEndpoint } from '@/utils/common'
const base64Url = ref('')
const success = ref(false)
const isLoading = ref(true)
const hasError = ref(false)
const props = defineProps<{
item: {
key: string
isImage: boolean
fileName: string | null | undefined
}
url: string
config: any
isShowThumbnail: boolean
}>()
const imageSource = computed(() => {
return props.isShowThumbnail && props.item.isImage && success.value
? base64Url.value
: `./assets/icons/${getFileIconPath(props.item.fileName ?? '')}`
})
const iconPath = computed(() => `./assets/icons/${getFileIconPath(props.item.fileName ?? '')}`)
async function getWebdavHeader(key: string) {
let headers = {} as any
if (props.config.authType === 'digest') {
const authHeader = await getAuthHeader(
'GET',
formatEndpoint(props.config.endpoint, props.config.sslEnabled || false),
`/${key.replace(/^\//, '')}`,
props.config.username,
props.config.password
)
headers = {
Authorization: authHeader
}
} else {
headers = {
Authorization: 'Basic ' + Buffer.from(`${props.config.username}:${props.config.password}`).toString('base64')
}
}
return headers
}
const fetchImage = async () => {
try {
isLoading.value = true
hasError.value = false
const headers = await getWebdavHeader(props.item.key)
const res = await fetch(props.url, { method: 'GET', headers })
if (res.status >= 200 && res.status < 300) {
const blob = await res.blob()
success.value = true
base64Url.value = URL.createObjectURL(blob)
isLoading.value = false
} else {
throw new Error('Network response was not ok.')
}
} catch (err) {
success.value = false
hasError.value = true
isLoading.value = false
console.log(err)
}
}
const handleImageLoad = () => {
isLoading.value = false
hasError.value = false
}
const handleImageError = () => {
isLoading.value = false
hasError.value = true
}
watch(() => [props.url, props.item], fetchImage, { deep: true })
onMounted(fetchImage)
</script>
<style scoped>
.image-container {
height: 100px;
width: 100%;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.image {
max-height: 100%;
max-width: 100%;
object-fit: contain;
display: block;
}
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e4e7ed;
border-top: 2px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,22 +1,12 @@
<template>
<Teleport to="body">
<div
v-if="showInputBoxVisible"
class="inputbox-overlay"
@click="handleInputBoxCancel"
>
<div
class="inputbox-container"
@click.stop
>
<div v-if="showInputBoxVisible" class="inputbox-overlay" @click="handleInputBoxCancel">
<div class="inputbox-container" @click.stop>
<div class="inputbox-header">
<h3 class="inputbox-title">
{{ inputBoxOptions.title || t('pages.inputBox.title') }}
</h3>
<button
class="inputbox-close"
@click="handleInputBoxCancel"
>
<button class="inputbox-close" @click="handleInputBoxCancel">
<X :size="20" />
</button>
</div>
@@ -28,19 +18,13 @@
type="text"
@keyup.enter="handleInputBoxConfirm"
@keyup.escape="handleInputBoxCancel"
>
/>
</div>
<div class="inputbox-actions">
<button
class="inputbox-btn cancel-btn"
@click="handleInputBoxCancel"
>
<button class="inputbox-btn cancel-btn" @click="handleInputBoxCancel">
{{ t('common.cancel') }}
</button>
<button
class="inputbox-btn confirm-btn primary"
@click="handleInputBoxConfirm"
>
<button class="inputbox-btn confirm-btn primary" @click="handleInputBoxConfirm">
{{ t('common.confirm') }}
</button>
</div>
@@ -66,27 +50,27 @@ const inputBoxOptions = reactive({
placeholder: ''
})
let removeInputBoxListenerCallback: (() => void) = () => {}
let removeInputBoxListenerCallback: () => void = () => {}
function handleIpcInputBoxEvent (options: IShowInputBoxOption) {
function handleIpcInputBoxEvent(options: IShowInputBoxOption) {
initInputBoxValue(options)
}
function initInputBoxValue (options: IShowInputBoxOption) {
function initInputBoxValue(options: IShowInputBoxOption) {
inputBoxValue.value = options.value || ''
inputBoxOptions.title = options.title || ''
inputBoxOptions.placeholder = options.placeholder || ''
showInputBoxVisible.value = true
}
function handleInputBoxCancel () {
function handleInputBoxCancel() {
// TODO: RPCServer
showInputBoxVisible.value = false
window.electron.sendToMain(SHOW_INPUT_BOX, '')
$bus.emit(SHOW_INPUT_BOX_RESPONSE, '')
}
function handleInputBoxConfirm () {
function handleInputBoxConfirm() {
showInputBoxVisible.value = false
window.electron.sendToMain(SHOW_INPUT_BOX, inputBoxValue.value)
$bus.emit(SHOW_INPUT_BOX_RESPONSE, inputBoxValue.value)
@@ -126,7 +110,9 @@ export default {
.inputbox-container {
background: white;
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 32rem;
width: 90%;
max-height: 80vh;

File diff suppressed because it is too large Load Diff

View File

@@ -1,61 +1,57 @@
<template>
<div class="toolbox-handler">
<button
class="handler-button"
@click="() => props.handler(value)"
>
{{ props.handlerText }}
</button>
</div>
</template>
<script lang="ts" setup>
interface IProps {
status: string
value: any
handlerText: string
handler: (value: any) => void | Promise<void>
}
const props = defineProps<IProps>()
</script>
<script lang="ts">
export default {
name: 'ToolboxHandler'
}
</script>
<style lang="stylus">
.toolbox-handler {
margin-top: 0.75rem;
}
.handler-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition-fast);
font-family: inherit;
text-decoration: none;
}
.handler-button:hover {
background: var(--color-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.handler-button:active {
transform: translateY(0);
}
</style>
<template>
<div class="toolbox-handler">
<button class="handler-button" @click="() => props.handler(value)">
{{ props.handlerText }}
</button>
</div>
</template>
<script lang="ts" setup>
interface IProps {
status: string
value: any
handlerText: string
handler: (value: any) => void | Promise<void>
}
const props = defineProps<IProps>()
</script>
<script lang="ts">
export default {
name: 'ToolboxHandler'
}
</script>
<style lang="stylus">
.toolbox-handler {
margin-top: 0.75rem;
}
.handler-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition-fast);
font-family: inherit;
text-decoration: none;
}
.handler-button:hover {
background: var(--color-accent-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.handler-button:active {
transform: translateY(0);
}
</style>

View File

@@ -1,9 +1,5 @@
<template>
<component
:is="icon"
class="toolbox-status-icon"
:style="{ color }"
/>
<component :is="icon" class="toolbox-status-icon" :style="{ color }" />
</template>
<script lang="ts" setup>

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,6 @@
<template>
<div
ref="containerRef"
class="virtual-scroller"
:style="{ height: `${containerHeight}px` }"
@scroll="handleScroll"
>
<div
class="virtual-scroller-content"
:style="contentStyles"
>
<div ref="containerRef" class="virtual-scroller" :style="{ height: `${containerHeight}px` }" @scroll="handleScroll">
<div class="virtual-scroller-content" :style="contentStyles">
<div
class="virtual-scroller-viewport"
:class="{ 'is-grid': isGridMode, 'is-list': !isGridMode }"
@@ -16,14 +8,15 @@
>
<div
v-for="realIndex in visibleIndexes"
:key="itemsRef[realIndex] && itemsRef[realIndex][props.keyField || 'id'] ? itemsRef[realIndex][props.keyField || 'id'] : realIndex"
:key="
itemsRef[realIndex] && itemsRef[realIndex][props.keyField || 'id']
? itemsRef[realIndex][props.keyField || 'id']
: realIndex
"
class="virtual-scroller-item"
:style="itemStyle"
>
<slot
:item="itemsRef[realIndex]"
:index="realIndex"
/>
<slot :item="itemsRef[realIndex]" :index="realIndex" />
</div>
</div>
</div>
@@ -36,29 +29,35 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useVirtualGrid } from '@/hooks/useVirtualGrid'
type Item = any
interface Breakpoint { min: number; cols: number }
interface Breakpoint {
min: number
cols: number
}
const props = withDefaults(defineProps<{
items: Item[]
itemHeight: number
height?: number
gridItems?: number
gridBreakpoints?: Breakpoint[]
bufferFactor?: number
pageMode?: boolean
keyField?: string
itemPadding?: number
viewMode?: 'list' | 'grid'
}>(), {
height: 400,
gridItems: 1,
gridBreakpoints: () => [],
bufferFactor: 0.5,
pageMode: false,
keyField: 'id',
itemPadding: 0,
viewMode: 'grid'
})
const props = withDefaults(
defineProps<{
items: Item[]
itemHeight: number
height?: number
gridItems?: number
gridBreakpoints?: Breakpoint[]
bufferFactor?: number
pageMode?: boolean
keyField?: string
itemPadding?: number
viewMode?: 'list' | 'grid'
}>(),
{
height: 400,
gridItems: 1,
gridBreakpoints: () => [],
bufferFactor: 0.5,
pageMode: false,
keyField: 'id',
itemPadding: 0,
viewMode: 'grid'
}
)
const containerRef = ref<HTMLElement | null>(null)
const containerHeight = ref<number>(props.pageMode ? 0 : props.height)
@@ -66,15 +65,23 @@ const containerWidth = ref<number>(0)
const parentScrollListeners = ref<HTMLElement[]>([])
const itemsRef = ref<Item[]>(props.items)
watch(() => props.items, v => { itemsRef.value = v })
const localViewMode = ref< 'list' | 'grid'>(props.viewMode)
watch(() => props.viewMode, v => { localViewMode.value = v })
const sortedBreakpoints = computed<Breakpoint[]>(() =>
[...props.gridBreakpoints].sort((a, b) => a.min - b.min)
watch(
() => props.items,
v => {
itemsRef.value = v
}
)
const localViewMode = ref<'list' | 'grid'>(props.viewMode)
watch(
() => props.viewMode,
v => {
localViewMode.value = v
}
)
const sortedBreakpoints = computed<Breakpoint[]>(() => [...props.gridBreakpoints].sort((a, b) => a.min - b.min))
const isForcedList = computed(() => localViewMode.value === 'list')
const effectiveCols = computed<number>(() => {
@@ -92,19 +99,14 @@ const effectiveCols = computed<number>(() => {
const isGridMode = computed(() => effectiveCols.value > 1)
const {
gridCalculations,
visibleIndexes,
viewportOffset,
updateScrollTop,
scrollToItem, scrollToTop, scrollToBottom
} = useVirtualGrid({
items: itemsRef,
itemHeight: props.itemHeight,
containerHeight,
gridItems: effectiveCols,
bufferFactor: props.bufferFactor
})
const { gridCalculations, visibleIndexes, viewportOffset, updateScrollTop, scrollToItem, scrollToTop, scrollToBottom } =
useVirtualGrid({
items: itemsRef,
itemHeight: props.itemHeight,
containerHeight,
gridItems: effectiveCols,
bufferFactor: props.bufferFactor
})
const contentStyles = computed(() => ({
height: `${gridCalculations.value.totalHeight}px`
@@ -122,19 +124,15 @@ const viewportStyle = computed(() => {
return base
})
const itemStyle = computed(() =>
isGridMode.value
? {}
: { height: `${props.itemHeight}px` }
)
const itemStyle = computed(() => (isGridMode.value ? {} : { height: `${props.itemHeight}px` }))
function handleScroll () {
function handleScroll() {
const c = containerRef.value
if (!c) return
updateScrollTop(c.scrollTop)
}
function handlePageScroll () {
function handlePageScroll() {
if (!props.pageMode) return
const now = Date.now()
if (now - lastScrollTime.value < 16) return
@@ -159,7 +157,7 @@ function handlePageScroll () {
let ro: ResizeObserver | null = null
const lastScrollTime = ref(0)
function updateContainerMetrics () {
function updateContainerMetrics() {
const el = containerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
@@ -206,16 +204,18 @@ onBeforeUnmount(() => {
}
})
function scrollTo (index: number) { scrollToItem(index) }
function scrollTo(index: number) {
scrollToItem(index)
}
function setViewMode (mode: 'list' | 'grid') {
function setViewMode(mode: 'list' | 'grid') {
localViewMode.value = mode
}
function toggleViewMode () {
function toggleViewMode() {
setViewMode(isGridMode.value ? 'list' : 'grid')
}
function refresh () {
function refresh() {
updateContainerMetrics()
if (containerRef.value) {
updateScrollTop(containerRef.value.scrollTop)
@@ -266,5 +266,4 @@ defineExpose({ scrollTo, scrollToTop, scrollToBottom, setViewMode, toggleViewMod
.virtual-scroller-viewport.is-grid .virtual-scroller-item {
width: 100%;
}
</style>

View File

@@ -1,342 +1,305 @@
<template>
<div
v-if="isOpen"
class="messagebox-overlay"
@click="onCancel"
>
<div
class="messagebox-container"
@click.stop
>
<div class="messagebox-header">
<h3 class="messagebox-title">
{{ title }}
</h3>
<button
v-if="showClose"
class="messagebox-close"
@click="onCancel"
>
×
</button>
</div>
<div class="messagebox-content">
<div
v-if="type"
class="messagebox-icon"
>
<component
:is="iconComponent"
:size="48"
/>
</div>
<div class="messagebox-message">
<p>{{ message }}</p>
</div>
</div>
<div
v-if="center"
class="messagebox-actions center"
>
<button
class="messagebox-btn cancel-btn"
@click="onCancel"
>
{{ cancelButtonText }}
</button>
<button
class="messagebox-btn confirm-btn"
:class="confirmButtonClass"
@click="onConfirm"
>
{{ confirmButtonText }}
</button>
</div>
<div
v-else
class="messagebox-actions"
>
<button
class="messagebox-btn confirm-btn"
:class="confirmButtonClass"
@click="onConfirm"
>
{{ confirmButtonText }}
</button>
<button
class="messagebox-btn cancel-btn"
@click="onCancel"
>
{{ cancelButtonText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle, Info, XCircle } from 'lucide-vue-next'
import { computed } from 'vue'
interface Props {
isOpen: boolean
title?: string
message: string
type?: 'info' | 'success' | 'warning' | 'error'
confirmButtonText?: string
cancelButtonText?: string
showClose?: boolean
center?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: 'Confirm',
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
showClose: true,
center: false,
type: undefined
})
const emit = defineEmits<Emits>()
const iconComponent = computed(() => {
switch (props.type) {
case 'warning':
return AlertTriangle
case 'info':
return Info
case 'success':
return CheckCircle
case 'error':
return XCircle
default:
return Info
}
})
const confirmButtonClass = computed(() => {
switch (props.type) {
case 'warning':
case 'error':
return 'danger'
case 'success':
return 'success'
default:
return 'primary'
}
})
const onConfirm = () => {
emit('confirm')
}
const onCancel = () => {
emit('cancel')
}
</script>
<script lang="ts">
export default {
name: 'ConfirmMessageBox'
}
</script>
<style scoped>
.messagebox-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.messagebox-container {
background: white;
border-radius: 0.75rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 32rem;
width: 90%;
max-height: 80vh;
overflow: hidden;
}
:root.dark .messagebox-container,
:root.auto.dark .messagebox-container {
background: rgb(31 41 55);
border: 1px solid rgb(55 65 81);
}
.messagebox-header {
padding: 1.5rem 1.5rem 0 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.messagebox-title {
font-size: 1.25rem;
font-weight: 600;
color: rgb(17 24 39);
margin: 0;
}
:root.dark .messagebox-title,
:root.auto.dark .messagebox-title {
color: rgb(243 244 246);
}
.messagebox-close {
background: none;
border: none;
font-size: 1.5rem;
color: rgb(107 114 128);
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.messagebox-close:hover {
background: rgb(243 244 246);
color: rgb(17 24 39);
}
:root.dark .messagebox-close,
:root.auto.dark .messagebox-close {
color: rgb(156 163 175);
}
:root.dark .messagebox-close:hover,
:root.auto.dark .messagebox-close:hover {
background: rgb(55 65 81);
color: rgb(243 244 246);
}
.messagebox-content {
padding: 1rem 1.5rem;
display: flex;
gap: 1rem;
align-items: flex-start;
}
.messagebox-icon {
flex-shrink: 0;
color: rgb(107 114 128);
}
.messagebox-icon svg[data-lucide="alert-triangle"] {
color: rgb(245 158 11);
}
.messagebox-icon svg[data-lucide="info"] {
color: rgb(59 130 246);
}
.messagebox-icon svg[data-lucide="check-circle"] {
color: rgb(34 197 94);
}
.messagebox-icon svg[data-lucide="x-circle"] {
color: rgb(239 68 68);
}
.messagebox-message {
flex: 1;
}
.messagebox-message p {
color: rgb(107 114 128);
line-height: 1.6;
margin: 0;
}
:root.dark .messagebox-message p,
:root.auto.dark .messagebox-message p {
color: rgb(156 163 175);
}
.messagebox-actions {
display: flex;
gap: 0.75rem;
padding: 0 1.5rem 1.5rem 1.5rem;
justify-content: flex-end;
}
.messagebox-actions.center {
justify-content: center;
}
.messagebox-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
min-width: 4rem;
}
.cancel-btn {
background: rgb(243 244 246);
color: rgb(75 85 99);
border: 1px solid rgb(209 213 219);
}
.cancel-btn:hover {
background: rgb(229 231 235);
}
:root.dark .cancel-btn,
:root.auto.dark .cancel-btn {
background: rgb(55 65 81);
color: rgb(209 213 219);
border-color: rgb(75 85 99);
}
:root.dark .cancel-btn:hover,
:root.auto.dark .cancel-btn:hover {
background: rgb(75 85 99);
}
.confirm-btn.primary {
background: rgb(59 130 246);
color: white;
}
.confirm-btn.primary:hover {
background: rgb(37 99 235);
}
.confirm-btn.danger {
background: rgb(239 68 68);
color: white;
}
.confirm-btn.danger:hover {
background: rgb(220 38 38);
}
.confirm-btn.success {
background: rgb(34 197 94);
color: white;
}
.confirm-btn.success:hover {
background: rgb(22 163 74);
}
</style>
<template>
<div v-if="isOpen" class="messagebox-overlay" @click="onCancel">
<div class="messagebox-container" @click.stop>
<div class="messagebox-header">
<h3 class="messagebox-title">
{{ title }}
</h3>
<button v-if="showClose" class="messagebox-close" @click="onCancel">×</button>
</div>
<div class="messagebox-content">
<div v-if="type" class="messagebox-icon">
<component :is="iconComponent" :size="48" />
</div>
<div class="messagebox-message">
<p>{{ message }}</p>
</div>
</div>
<div v-if="center" class="messagebox-actions center">
<button class="messagebox-btn cancel-btn" @click="onCancel">
{{ cancelButtonText }}
</button>
<button class="messagebox-btn confirm-btn" :class="confirmButtonClass" @click="onConfirm">
{{ confirmButtonText }}
</button>
</div>
<div v-else class="messagebox-actions">
<button class="messagebox-btn confirm-btn" :class="confirmButtonClass" @click="onConfirm">
{{ confirmButtonText }}
</button>
<button class="messagebox-btn cancel-btn" @click="onCancel">
{{ cancelButtonText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle, Info, XCircle } from 'lucide-vue-next'
import { computed } from 'vue'
interface Props {
isOpen: boolean
title?: string
message: string
type?: 'info' | 'success' | 'warning' | 'error'
confirmButtonText?: string
cancelButtonText?: string
showClose?: boolean
center?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
title: 'Confirm',
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
showClose: true,
center: false,
type: undefined
})
const emit = defineEmits<Emits>()
const iconComponent = computed(() => {
switch (props.type) {
case 'warning':
return AlertTriangle
case 'info':
return Info
case 'success':
return CheckCircle
case 'error':
return XCircle
default:
return Info
}
})
const confirmButtonClass = computed(() => {
switch (props.type) {
case 'warning':
case 'error':
return 'danger'
case 'success':
return 'success'
default:
return 'primary'
}
})
const onConfirm = () => {
emit('confirm')
}
const onCancel = () => {
emit('cancel')
}
</script>
<script lang="ts">
export default {
name: 'ConfirmMessageBox'
}
</script>
<style scoped>
.messagebox-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.messagebox-container {
background: white;
border-radius: 0.75rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 32rem;
width: 90%;
max-height: 80vh;
overflow: hidden;
}
:root.dark .messagebox-container,
:root.auto.dark .messagebox-container {
background: rgb(31 41 55);
border: 1px solid rgb(55 65 81);
}
.messagebox-header {
padding: 1.5rem 1.5rem 0 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.messagebox-title {
font-size: 1.25rem;
font-weight: 600;
color: rgb(17 24 39);
margin: 0;
}
:root.dark .messagebox-title,
:root.auto.dark .messagebox-title {
color: rgb(243 244 246);
}
.messagebox-close {
background: none;
border: none;
font-size: 1.5rem;
color: rgb(107 114 128);
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.messagebox-close:hover {
background: rgb(243 244 246);
color: rgb(17 24 39);
}
:root.dark .messagebox-close,
:root.auto.dark .messagebox-close {
color: rgb(156 163 175);
}
:root.dark .messagebox-close:hover,
:root.auto.dark .messagebox-close:hover {
background: rgb(55 65 81);
color: rgb(243 244 246);
}
.messagebox-content {
padding: 1rem 1.5rem;
display: flex;
gap: 1rem;
align-items: flex-start;
}
.messagebox-icon {
flex-shrink: 0;
color: rgb(107 114 128);
}
.messagebox-icon svg[data-lucide='alert-triangle'] {
color: rgb(245 158 11);
}
.messagebox-icon svg[data-lucide='info'] {
color: rgb(59 130 246);
}
.messagebox-icon svg[data-lucide='check-circle'] {
color: rgb(34 197 94);
}
.messagebox-icon svg[data-lucide='x-circle'] {
color: rgb(239 68 68);
}
.messagebox-message {
flex: 1;
}
.messagebox-message p {
color: rgb(107 114 128);
line-height: 1.6;
margin: 0;
}
:root.dark .messagebox-message p,
:root.auto.dark .messagebox-message p {
color: rgb(156 163 175);
}
.messagebox-actions {
display: flex;
gap: 0.75rem;
padding: 0 1.5rem 1.5rem 1.5rem;
justify-content: flex-end;
}
.messagebox-actions.center {
justify-content: center;
}
.messagebox-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
min-width: 4rem;
}
.cancel-btn {
background: rgb(243 244 246);
color: rgb(75 85 99);
border: 1px solid rgb(209 213 219);
}
.cancel-btn:hover {
background: rgb(229 231 235);
}
:root.dark .cancel-btn,
:root.auto.dark .cancel-btn {
background: rgb(55 65 81);
color: rgb(209 213 219);
border-color: rgb(75 85 99);
}
:root.dark .cancel-btn:hover,
:root.auto.dark .cancel-btn:hover {
background: rgb(75 85 99);
}
.confirm-btn.primary {
background: rgb(59 130 246);
color: white;
}
.confirm-btn.primary:hover {
background: rgb(37 99 235);
}
.confirm-btn.danger {
background: rgb(239 68 68);
color: white;
}
.confirm-btn.danger:hover {
background: rgb(220 38 38);
}
.confirm-btn.success {
background: rgb(34 197 94);
color: white;
}
.confirm-btn.success:hover {
background: rgb(22 163 74);
}
</style>

View File

@@ -1,270 +1,257 @@
<template>
<Teleport to="body">
<div class="message-container">
<TransitionGroup
name="message"
tag="div"
>
<div
v-for="message in messages"
:key="message.id"
class="message-toast"
:class="getMessageClass(message.type)"
>
<div class="message-icon">
<component
:is="getIconComponent(message.type)"
:size="20"
/>
</div>
<div class="message-content">
{{ message.message }}
</div>
<button
v-if="message.showClose"
class="message-close"
@click="removeMessage(message.id)"
>
<X :size="16" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle, Info, X, XCircle } from 'lucide-vue-next'
import { reactive } from 'vue'
export interface MessageOptions {
message: string
type?: 'success' | 'warning' | 'info' | 'error'
duration?: number
showClose?: boolean
}
interface MessageItem extends MessageOptions {
id: string
timer?: ReturnType<typeof setTimeout>
}
const messages = reactive<MessageItem[]>([])
const getIconComponent = (type: MessageOptions['type']) => {
switch (type) {
case 'success':
return CheckCircle
case 'warning':
return AlertTriangle
case 'error':
return XCircle
default:
return Info
}
}
const getMessageClass = (type: MessageOptions['type']) => {
return `message-${type || 'info'}`
}
const removeMessage = (id: string) => {
const index = messages.findIndex(msg => msg.id === id)
if (index > -1) {
const message = messages[index]
if (message.timer) {
clearTimeout(message.timer)
}
messages.splice(index, 1)
}
}
const addMessage = (options: MessageOptions) => {
const id = `message-${Date.now()}-${Math.random()}`
const duration = options.duration ?? 3000
const showClose = options.showClose ?? true
const message: MessageItem = {
id,
...options,
showClose
}
if (duration > 0) {
message.timer = setTimeout(() => {
removeMessage(id)
}, duration)
}
messages.push(message)
return id
}
// Expose methods for external use
const success = (message: string, options?: Partial<MessageOptions>) => {
return addMessage({ message, type: 'success', ...options })
}
const error = (message: string, options?: Partial<MessageOptions>) => {
return addMessage({ message, type: 'error', ...options })
}
const warning = (message: string, options?: Partial<MessageOptions>) => {
return addMessage({ message, type: 'warning', ...options })
}
const info = (message: string, options?: Partial<MessageOptions>) => {
return addMessage({ message, type: 'info', ...options })
}
defineExpose({
success,
error,
warning,
info,
addMessage,
removeMessage
})
</script>
<script lang="ts">
export default {
name: 'MessageToast'
}
</script>
<style scoped>
.message-container {
position: fixed;
top: 34px;
right: 20px;
z-index: 3000;
pointer-events: none;
}
.message-toast {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
max-width: 24rem;
min-width: 20rem;
pointer-events: all;
background: white;
border: 1px solid rgb(229 231 235);
word-wrap: break-word;
}
:root.dark .message-toast,
:root.auto.dark .message-toast {
background: rgb(31 41 55);
border-color: rgb(55 65 81);
}
.message-info {
border-left: 4px solid rgb(59 130 246);
}
.message-info .message-icon {
color: rgb(59 130 246);
}
.message-success {
border-left: 4px solid rgb(34 197 94);
}
.message-success .message-icon {
color: rgb(34 197 94);
}
.message-warning {
border-left: 4px solid rgb(245 158 11);
}
.message-warning .message-icon {
color: rgb(245 158 11);
}
.message-error {
border-left: 4px solid rgb(239 68 68);
}
.message-error .message-icon {
color: rgb(239 68 68);
}
.message-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
.message-content {
flex: 1;
color: rgb(75 85 99);
font-size: 0.875rem;
line-height: 1.25rem;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
min-width: 0;
}
:root.dark .message-content,
:root.auto.dark .message-content {
color: rgb(209 213 219);
}
.message-close {
background: none;
border: none;
color: rgb(107 114 128);
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 0.125rem;
}
.message-close:hover {
background: rgb(243 244 246);
color: rgb(75 85 99);
}
:root.dark .message-close,
:root.auto.dark .message-close {
color: rgb(156 163 175);
}
:root.dark .message-close:hover,
:root.auto.dark .message-close:hover {
background: rgb(55 65 81);
color: rgb(209 213 219);
}
/* Transition animations */
.message-enter-active,
.message-leave-active {
transition: all 0.3s ease;
}
.message-enter-from {
opacity: 0;
transform: translateX(100%);
}
.message-leave-to {
opacity: 0;
transform: translateX(100%);
}
.message-move {
transition: transform 0.3s ease;
}
</style>
<template>
<Teleport to="body">
<div class="message-container">
<TransitionGroup name="message" tag="div">
<div v-for="message in messages" :key="message.id" class="message-toast" :class="getMessageClass(message.type)">
<div class="message-icon">
<component :is="getIconComponent(message.type)" :size="20" />
</div>
<div class="message-content">
{{ message.message }}
</div>
<button v-if="message.showClose" class="message-close" @click="removeMessage(message.id)">
<X :size="16" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { AlertTriangle, CheckCircle, Info, X, XCircle } from 'lucide-vue-next'
import { reactive } from 'vue'
export interface MessageOptions {
message: string
type?: 'success' | 'warning' | 'info' | 'error'
duration?: number
showClose?: boolean
}
interface MessageItem extends MessageOptions {
id: string
timer?: ReturnType<typeof setTimeout>
}
const messages = reactive<MessageItem[]>([])
const getIconComponent = (type: MessageOptions['type']) => {
switch (type) {
case 'success':
return CheckCircle
case 'warning':
return AlertTriangle
case 'error':
return XCircle
default:
return Info
}
}
const getMessageClass = (type: MessageOptions['type']) => {
return `message-${type || 'info'}`
}
const removeMessage = (id: string) => {
const index = messages.findIndex(msg => msg.id === id)
if (index > -1) {
const message = messages[index]
if (message.timer) {
clearTimeout(message.timer)
}
messages.splice(index, 1)
}
}
const addMessage = (options: MessageOptions) => {
const id = `message-${Date.now()}-${Math.random()}`
const duration = options.duration ?? 3000
const showClose = options.showClose ?? true
const message: MessageItem = {
id,
...options,
showClose
}
if (duration > 0) {
message.timer = setTimeout(() => {
removeMessage(id)
}, duration)
}
messages.push(message)
return id
}
// Expose methods for external use
const success = (message: string, options?: Partial<MessageOptions>) => {
return addMessage({ message, type: 'success', ...options })
}
const error = (message: string, options?: Partial<MessageOptions>) => {
return addMessage({ message, type: 'error', ...options })
}
const warning = (message: string, options?: Partial<MessageOptions>) => {
return addMessage({ message, type: 'warning', ...options })
}
const info = (message: string, options?: Partial<MessageOptions>) => {
return addMessage({ message, type: 'info', ...options })
}
defineExpose({
success,
error,
warning,
info,
addMessage,
removeMessage
})
</script>
<script lang="ts">
export default {
name: 'MessageToast'
}
</script>
<style scoped>
.message-container {
position: fixed;
top: 34px;
right: 20px;
z-index: 3000;
pointer-events: none;
}
.message-toast {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border-radius: 0.5rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
max-width: 24rem;
min-width: 20rem;
pointer-events: all;
background: white;
border: 1px solid rgb(229 231 235);
word-wrap: break-word;
}
:root.dark .message-toast,
:root.auto.dark .message-toast {
background: rgb(31 41 55);
border-color: rgb(55 65 81);
}
.message-info {
border-left: 4px solid rgb(59 130 246);
}
.message-info .message-icon {
color: rgb(59 130 246);
}
.message-success {
border-left: 4px solid rgb(34 197 94);
}
.message-success .message-icon {
color: rgb(34 197 94);
}
.message-warning {
border-left: 4px solid rgb(245 158 11);
}
.message-warning .message-icon {
color: rgb(245 158 11);
}
.message-error {
border-left: 4px solid rgb(239 68 68);
}
.message-error .message-icon {
color: rgb(239 68 68);
}
.message-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
.message-content {
flex: 1;
color: rgb(75 85 99);
font-size: 0.875rem;
line-height: 1.25rem;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
hyphens: auto;
min-width: 0;
}
:root.dark .message-content,
:root.auto.dark .message-content {
color: rgb(209 213 219);
}
.message-close {
background: none;
border: none;
color: rgb(107 114 128);
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 0.125rem;
}
.message-close:hover {
background: rgb(243 244 246);
color: rgb(75 85 99);
}
:root.dark .message-close,
:root.auto.dark .message-close {
color: rgb(156 163 175);
}
:root.dark .message-close:hover,
:root.auto.dark .message-close:hover {
background: rgb(55 65 81);
color: rgb(209 213 219);
}
/* Transition animations */
.message-enter-active,
.message-leave-active {
transition: all 0.3s ease;
}
.message-enter-from {
opacity: 0;
transform: translateX(100%);
}
.message-leave-to {
opacity: 0;
transform: translateX(100%);
}
.message-move {
transition: transform 0.3s ease;
}
</style>

View File

@@ -1,117 +1,106 @@
<script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/hooks/useAppStore'
interface Props {
collapsed?: boolean
}
defineProps<Props>()
const { t } = useI18n()
const appStore = useAppStore()
const currentTheme = computed(() => appStore.settings.app.theme || 'light')
const themeOptions = computed(() => [
{
value: 'light',
label: t('settings.theme.light'),
icon: Sun,
description: t('settings.theme.lightDesc')
},
{
value: 'dark',
label: t('settings.theme.dark'),
icon: Moon,
description: t('settings.theme.darkDesc')
},
{
value: 'auto',
label: t('settings.theme.auto'),
icon: Monitor,
description: t('settings.theme.autoDesc')
}
])
const currentThemeOption = computed(
() => themeOptions.value.find(option => option.value === currentTheme.value) || themeOptions.value[0]
)
const toggleTheme = () => {
appStore.toggleTheme()
}
</script>
<template>
<div class="theme-switcher">
<button
class="theme-toggle-btn"
:class="{ collapsed }"
:title="t('settings.theme.toggle')"
@click="toggleTheme"
>
<component
:is="currentThemeOption.icon"
:size="18"
/>
<span
v-if="!collapsed"
class="theme-label"
>{{ currentThemeOption.label }}</span>
</button>
</div>
</template>
<style scoped>
.theme-switcher {
position: relative;
display: flex;
align-items: center;
}
.theme-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.1);
color: var(--color-text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.theme-toggle-btn.collapsed {
padding: 0.5rem;
gap: 0;
justify-content: center;
}
.theme-toggle-btn:hover {
background: var(--color-surface-elevated);
color: var(--color-text-primary);
}
.theme-label {
font-weight: 500;
}
/* Mobile responsive */
@media (max-width: 768px) {
.theme-label {
display: none;
}
.theme-toggle-btn {
padding: 0.5rem;
gap: 0;
justify-content: center;
}
}
</style>
<script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/hooks/useAppStore'
interface Props {
collapsed?: boolean
}
defineProps<Props>()
const { t } = useI18n()
const appStore = useAppStore()
const currentTheme = computed(() => appStore.settings.app.theme || 'light')
const themeOptions = computed(() => [
{
value: 'light',
label: t('settings.theme.light'),
icon: Sun,
description: t('settings.theme.lightDesc')
},
{
value: 'dark',
label: t('settings.theme.dark'),
icon: Moon,
description: t('settings.theme.darkDesc')
},
{
value: 'auto',
label: t('settings.theme.auto'),
icon: Monitor,
description: t('settings.theme.autoDesc')
}
])
const currentThemeOption = computed(
() => themeOptions.value.find(option => option.value === currentTheme.value) || themeOptions.value[0]
)
const toggleTheme = () => {
appStore.toggleTheme()
}
</script>
<template>
<div class="theme-switcher">
<button class="theme-toggle-btn" :class="{ collapsed }" :title="t('settings.theme.toggle')" @click="toggleTheme">
<component :is="currentThemeOption.icon" :size="18" />
<span v-if="!collapsed" class="theme-label">{{ currentThemeOption.label }}</span>
</button>
</div>
</template>
<style scoped>
.theme-switcher {
position: relative;
display: flex;
align-items: center;
}
.theme-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.1);
color: var(--color-text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.theme-toggle-btn.collapsed {
padding: 0.5rem;
gap: 0;
justify-content: center;
}
.theme-toggle-btn:hover {
background: var(--color-surface-elevated);
color: var(--color-text-primary);
}
.theme-label {
font-weight: 500;
}
/* Mobile responsive */
@media (max-width: 768px) {
.theme-label {
display: none;
}
.theme-toggle-btn {
padding: 0.5rem;
gap: 0;
justify-content: center;
}
}
</style>

View File

@@ -1,246 +1,212 @@
<template>
<div
class="title-bar"
data-drag-region
>
<div class="title-bar-content">
<div
v-if="osGlobal !== 'darwin'"
class="title-left"
>
<div class="app-icon">
<img
:src="defaultLogo"
alt="App Icon"
width="20"
height="20"
>
</div>
</div>
<div class="title-center">
<!-- Progress bar in title bar -->
<div
v-if="isShowprogress"
class="progress-container"
>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: `${progress}%` }"
/>
</div>
<span class="progress-text">{{ progress }}%</span>
</div>
</div>
<div
class="title-right"
>
<div class="window-controls">
<button
class="control-button pin-button"
:class="{ active: isAlwaysOnTop }"
:title="$t('titleBar.alwaysOnTop')"
@click="setAlwaysOnTop"
>
<PinIcon
:color="isAlwaysOnTop ? '#CE6769' : '#6B7280'"
:size="14"
/>
</button>
<button
class="control-button minimize-button"
:title="$t('titleBar.minimize')"
@click="minimizeWindow"
>
<MinusIcon :size="14" />
</button>
<button
class="control-button mini-button"
:title="$t('titleBar.miniWindow')"
@click="openMiniWindow"
>
<ShrinkIcon :size="14" />
</button>
<button
class="control-button close-button"
:title="$t('titleBar.close')"
@click="closeWindow"
>
<XIcon :size="14" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MinusIcon, PinIcon, ShrinkIcon, XIcon } from 'lucide-vue-next'
import { computed, onBeforeMount, onBeforeUnmount, ref } from 'vue'
import { IRPCActionType } from '@/utils/enum'
import { osGlobal } from '@/utils/global'
const isShowprogress = ref(false)
const progress = ref(0)
const isAlwaysOnTop = ref(false)
const defaultLogo = computed(() => `${import.meta.env.BASE_URL}roundLogo.png`)
function setAlwaysOnTop () {
isAlwaysOnTop.value = !isAlwaysOnTop.value
window.electron.sendRPC(IRPCActionType.MAIN_WINDOW_ON_TOP)
}
function minimizeWindow () {
window.electron.sendRPC(IRPCActionType.MINIMIZE_WINDOW)
}
function openMiniWindow () {
window.electron.sendRPC(IRPCActionType.OPEN_MINI_WINDOW)
}
function closeWindow () {
window.electron.sendRPC(IRPCActionType.CLOSE_WINDOW)
}
const uploadProcessHandler = (data: { progress: number }) => {
isShowprogress.value = data.progress !== 100 && data.progress !== 0
progress.value = data.progress
}
onBeforeMount(() => {
window.electron.ipcRendererOn('updateProgress', uploadProcessHandler)
})
onBeforeUnmount(() => {
window.electron.ipcRendererRemoveAllListeners('updateProgress')
})
</script>
<style scoped>
.title-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 32px;
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
z-index: 1000;
-webkit-app-region: drag;
}
.title-bar-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 16px;
}
.title-left {
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
}
.app-icon {
display: flex;
align-items: center;
color: var(--color-accent);
}
.app-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.app-version {
font-size: 12px;
color: var(--color-text-secondary);
background: var(--color-border);
padding: 2px 6px;
border-radius: 4px;
}
.title-center {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
-webkit-app-region: no-drag;
}
.progress-container {
display: flex;
align-items: center;
gap: 8px;
max-width: 200px;
}
.progress-bar {
flex: 1;
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-success);
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 11px;
color: var(--color-text-secondary);
min-width: 35px;
}
.title-right {
display: flex;
align-items: center;
-webkit-app-region: no-drag;
}
.window-controls {
display: flex;
align-items: center;
gap: 8px;
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 20px;
border: none;
background: transparent;
border-radius: 4px;
color: var(--color-text-secondary);
cursor: pointer;
transition: var(--transition);
}
.control-button:hover {
background: var(--color-surface-elevated);
color: var(--color-text-primary);
}
.pin-button.active {
color: var(--color-accent);
background: var(--color-accent)20;
}
.close-button:hover {
background: var(--color-danger);
color: white;
}
</style>
<template>
<div class="title-bar" data-drag-region>
<div class="title-bar-content">
<div v-if="osGlobal !== 'darwin'" class="title-left">
<div class="app-icon">
<img :src="defaultLogo" alt="App Icon" width="20" height="20" />
</div>
</div>
<div class="title-center">
<!-- Progress bar in title bar -->
<div v-if="isShowprogress" class="progress-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: `${progress}%` }" />
</div>
<span class="progress-text">{{ progress }}%</span>
</div>
</div>
<div class="title-right">
<div class="window-controls">
<button
class="control-button pin-button"
:class="{ active: isAlwaysOnTop }"
:title="$t('titleBar.alwaysOnTop')"
@click="setAlwaysOnTop"
>
<PinIcon :color="isAlwaysOnTop ? '#CE6769' : '#6B7280'" :size="14" />
</button>
<button class="control-button minimize-button" :title="$t('titleBar.minimize')" @click="minimizeWindow">
<MinusIcon :size="14" />
</button>
<button class="control-button mini-button" :title="$t('titleBar.miniWindow')" @click="openMiniWindow">
<ShrinkIcon :size="14" />
</button>
<button class="control-button close-button" :title="$t('titleBar.close')" @click="closeWindow">
<XIcon :size="14" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MinusIcon, PinIcon, ShrinkIcon, XIcon } from 'lucide-vue-next'
import { computed, onBeforeMount, onBeforeUnmount, ref } from 'vue'
import { IRPCActionType } from '@/utils/enum'
import { osGlobal } from '@/utils/global'
const isShowprogress = ref(false)
const progress = ref(0)
const isAlwaysOnTop = ref(false)
const defaultLogo = computed(() => `${import.meta.env.BASE_URL}roundLogo.png`)
function setAlwaysOnTop() {
isAlwaysOnTop.value = !isAlwaysOnTop.value
window.electron.sendRPC(IRPCActionType.MAIN_WINDOW_ON_TOP)
}
function minimizeWindow() {
window.electron.sendRPC(IRPCActionType.MINIMIZE_WINDOW)
}
function openMiniWindow() {
window.electron.sendRPC(IRPCActionType.OPEN_MINI_WINDOW)
}
function closeWindow() {
window.electron.sendRPC(IRPCActionType.CLOSE_WINDOW)
}
const uploadProcessHandler = (data: { progress: number }) => {
isShowprogress.value = data.progress !== 100 && data.progress !== 0
progress.value = data.progress
}
onBeforeMount(() => {
window.electron.ipcRendererOn('updateProgress', uploadProcessHandler)
})
onBeforeUnmount(() => {
window.electron.ipcRendererRemoveAllListeners('updateProgress')
})
</script>
<style scoped>
.title-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 32px;
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
z-index: 1000;
-webkit-app-region: drag;
}
.title-bar-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 16px;
}
.title-left {
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
}
.app-icon {
display: flex;
align-items: center;
color: var(--color-accent);
}
.app-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
}
.app-version {
font-size: 12px;
color: var(--color-text-secondary);
background: var(--color-border);
padding: 2px 6px;
border-radius: 4px;
}
.title-center {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
-webkit-app-region: no-drag;
}
.progress-container {
display: flex;
align-items: center;
gap: 8px;
max-width: 200px;
}
.progress-bar {
flex: 1;
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-success);
border-radius: 2px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 11px;
color: var(--color-text-secondary);
min-width: 35px;
}
.title-right {
display: flex;
align-items: center;
-webkit-app-region: no-drag;
}
.window-controls {
display: flex;
align-items: center;
gap: 8px;
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 20px;
border: none;
background: transparent;
border-radius: 4px;
color: var(--color-text-secondary);
cursor: pointer;
transition: var(--transition);
}
.control-button:hover {
background: var(--color-surface-elevated);
color: var(--color-text-primary);
}
.pin-button.active {
color: var(--color-accent);
background: var(--color-accent) 20;
}
.close-button:hover {
background: var(--color-danger);
color: white;
}
</style>

View File

@@ -1,101 +1,101 @@
<template>
<div>
<!-- MessageToast component -->
<MessageToast ref="messageRef" />
<!-- ConfirmMessageBox component -->
<ConfirmMessageBox
:is-open="confirmVisible"
:title="confirmOptions.title"
:message="confirmOptions.message"
:type="confirmOptions.type"
:confirm-button-text="confirmOptions.confirmButtonText"
:cancel-button-text="confirmOptions.cancelButtonText"
:show-close="confirmOptions.showClose"
:center="confirmOptions.center"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import useConfirm, { type ConfirmOptions } from '@/hooks/useConfirm'
import useMessage from '@/hooks/useMessage'
import ConfirmMessageBox from './ConfirmMessageBox.vue'
import MessageToast from './MessageToast.vue'
const messageRef = ref<InstanceType<typeof MessageToast> | null>(null)
const confirmVisible = ref(false)
const confirmOptions = reactive<ConfirmOptions>({
message: '',
title: 'Confirm',
type: 'info',
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
showClose: true,
center: false
})
let confirmResolve: ((value: boolean) => void) | null = null
const handleConfirm = () => {
confirmVisible.value = false
if (confirmResolve) {
confirmResolve(true)
confirmResolve = null
}
}
const handleCancel = () => {
confirmVisible.value = false
if (confirmResolve) {
confirmResolve(false)
confirmResolve = null
}
}
const showConfirm = (options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
Object.assign(confirmOptions, {
title: 'Confirm',
type: 'info',
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
showClose: true,
center: false,
...options
})
confirmResolve = resolve
confirmVisible.value = true
})
}
onMounted(() => {
// Initialize message service
const { setMessageService } = useMessage()
if (messageRef.value) {
setMessageService({
success: messageRef.value.success,
error: messageRef.value.error,
warning: messageRef.value.warning,
info: messageRef.value.info
})
}
// Initialize confirm service
const { setConfirmService } = useConfirm()
setConfirmService({
confirm: showConfirm
})
})
</script>
<script lang="ts">
export default {
name: 'UIServiceProvider'
}
</script>
<template>
<div>
<!-- MessageToast component -->
<MessageToast ref="messageRef" />
<!-- ConfirmMessageBox component -->
<ConfirmMessageBox
:is-open="confirmVisible"
:title="confirmOptions.title"
:message="confirmOptions.message"
:type="confirmOptions.type"
:confirm-button-text="confirmOptions.confirmButtonText"
:cancel-button-text="confirmOptions.cancelButtonText"
:show-close="confirmOptions.showClose"
:center="confirmOptions.center"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import useConfirm, { type ConfirmOptions } from '@/hooks/useConfirm'
import useMessage from '@/hooks/useMessage'
import ConfirmMessageBox from './ConfirmMessageBox.vue'
import MessageToast from './MessageToast.vue'
const messageRef = ref<InstanceType<typeof MessageToast> | null>(null)
const confirmVisible = ref(false)
const confirmOptions = reactive<ConfirmOptions>({
message: '',
title: 'Confirm',
type: 'info',
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
showClose: true,
center: false
})
let confirmResolve: ((value: boolean) => void) | null = null
const handleConfirm = () => {
confirmVisible.value = false
if (confirmResolve) {
confirmResolve(true)
confirmResolve = null
}
}
const handleCancel = () => {
confirmVisible.value = false
if (confirmResolve) {
confirmResolve(false)
confirmResolve = null
}
}
const showConfirm = (options: ConfirmOptions): Promise<boolean> => {
return new Promise(resolve => {
Object.assign(confirmOptions, {
title: 'Confirm',
type: 'info',
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
showClose: true,
center: false,
...options
})
confirmResolve = resolve
confirmVisible.value = true
})
}
onMounted(() => {
// Initialize message service
const { setMessageService } = useMessage()
if (messageRef.value) {
setMessageService({
success: messageRef.value.success,
error: messageRef.value.error,
warning: messageRef.value.warning,
info: messageRef.value.info
})
}
// Initialize confirm service
const { setConfirmService } = useConfirm()
setConfirmService({
confirm: showConfirm
})
})
</script>
<script lang="ts">
export default {
name: 'UIServiceProvider'
}
</script>