mirror of
https://github.com/keven1024/015.git
synced 2026-06-07 12:54:34 +00:00
Compare commits
23 Commits
0.11.0-rc.
...
dev/0.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d62da572 | ||
|
|
3757ed606f | ||
|
|
26f1c52198 | ||
|
|
64f3d2e1d5 | ||
|
|
9d125ba9bd | ||
|
|
2e2698e281 | ||
|
|
ab0587bd4d | ||
|
|
ed9d39301f | ||
|
|
f1e956ad4c | ||
|
|
7793bef944 | ||
|
|
1fdd05f0ea | ||
|
|
7128a8c329 | ||
|
|
3cb878b770 | ||
|
|
05c3504627 | ||
|
|
9b1ba13ec3 | ||
|
|
2a4fac717a | ||
|
|
e897fe1ed3 | ||
|
|
0aae4c2d36 | ||
|
|
549bdd9f68 | ||
|
|
f956130b4f | ||
|
|
218b4cc6ac | ||
|
|
64a936835c | ||
|
|
b8e4bee050 |
@@ -13,10 +13,11 @@ jobs:
|
||||
node-version: '24'
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
cache: true
|
||||
version: latest
|
||||
cache: true
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install --frozen-lockfile
|
||||
- name: Run frontend lint
|
||||
run: pnpm lint:front
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
images: fudaoyuanicu/015-app
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=edge,enable=${{ contains(github.ref_name, '-') }}
|
||||
type=raw,value=edge
|
||||
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
|
||||
- name: Set build time
|
||||
id: build-time
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
images: fudaoyuanicu/015-worker
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=edge,enable=${{ contains(github.ref_name, '-') }}
|
||||
type=raw,value=edge
|
||||
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
|
||||
- name: Set build time
|
||||
id: build-time
|
||||
|
||||
@@ -64,3 +64,10 @@ about:
|
||||
name: keven
|
||||
url: 'https://fudaoyuan.icu'
|
||||
avatar: ''
|
||||
|
||||
smtp:
|
||||
host: example.com # SMTP服务器地址
|
||||
port: 465 # SMTP端口号,通常为465(SSL)或587(TLS)
|
||||
protocol: ssl # ssl or tls
|
||||
username: your@example.com # 发送方邮箱
|
||||
password: your-password # 发送方邮箱密码/授权码
|
||||
|
||||
@@ -8,6 +8,9 @@ import Progress from '~/components/ui/progress/Progress.vue'
|
||||
import renderI18n from '~/lib/renderI18n'
|
||||
import { I18nT } from 'vue-i18n'
|
||||
import { calcNativeHash } from '~/lib/calcFileHash'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
|
||||
import MarkdownRender from '@/components/MarkdownRender.vue'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const appConfig = useMyAppConfig()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
showBackButton?: boolean
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import asyncWait from '~/lib/asyncWait'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { LucideCheck, LucideCopy } from 'lucide-vue-next'
|
||||
import { LucideCheck, LucideCopy } from '@lucide/vue'
|
||||
|
||||
const isCopy = ref(false)
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -3,7 +3,7 @@ import FileUpload from '@/components/FileUpload.vue'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import type { RuleExpression } from 'vee-validate'
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { PlusIcon } from 'lucide-vue-next'
|
||||
import { PlusIcon } from '@lucide/vue'
|
||||
import { isEmpty } from 'lodash-es'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { LucideFileAudio, LucideFileVideo, LucideFile, LucideFileCode, LucideFileArchive, LucideFileText } from 'lucide-vue-next'
|
||||
import { LucideFileAudio, LucideFileVideo, LucideFile, LucideFileCode, LucideFileArchive, LucideFileText } from '@lucide/vue'
|
||||
import type { filePreview } from './Index.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { LucideSquare, LucideInfo, LucideFolders, LucideArrowUpFromLine, LucideCircleX, LucideCheckCircle, LucideLoaderCircle } from 'lucide-vue-next'
|
||||
import { LucideSquare, LucideInfo, LucideFolders, LucideArrowUpFromLine, LucideCircleX, LucideCheckCircle, LucideLoaderCircle } from '@lucide/vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import getFileSize from '~/lib/getFileSize'
|
||||
|
||||
@@ -22,9 +22,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { LucideClipboardType, LucidePaperclip } from '#components'
|
||||
import { motion } from 'motion-v'
|
||||
import { LucideGlobe } from 'lucide-vue-next'
|
||||
import { LucideGlobe, LucideClipboardType, LucidePaperclip } from '@lucide/vue'
|
||||
import showDrawer from '@/lib/showDrawer'
|
||||
import I18nSwitchDrawer from './Drawer/I18nSwitchDrawer.vue'
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -21,6 +21,7 @@ const { t } = useI18n()
|
||||
const { createFileShare } = useMyAppShare()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['create-share', ...props?.data?.files?.map((item) => item.id)],
|
||||
staleTime: Infinity,
|
||||
queryFn: async () => {
|
||||
const { files, config } = props?.data || {}
|
||||
const data = await createFileShare({
|
||||
|
||||
@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { isBoolean } from 'lodash-es'
|
||||
import { LucideCheck, LucideX } from 'lucide-vue-next'
|
||||
import { LucideCheck, LucideX } from '@lucide/vue'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import showDrawer from '~/lib/showDrawer'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
@@ -4,7 +4,7 @@ import AsyncButton from '@/components/ui/button/AsyncButton.vue'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { isBoolean } from 'lodash-es'
|
||||
import { LucideCheck, LucideX } from 'lucide-vue-next'
|
||||
import { LucideCheck, LucideX } from '@lucide/vue'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { toast } from 'vue-sonner'
|
||||
import MarkdownRender from '@/components/MarkdownRender.vue'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Markdown } from 'tiptap-markdown'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import countWords from '@/lib/countWords'
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
@@ -64,6 +65,6 @@ onUnmounted(() => {
|
||||
v-if="modelValue?.length && modelValue?.length > 0"
|
||||
class="absolute bottom-2 right-3 flex justify-end px-2 py-1 text-xs text-gray-400 select-none bg-white rounded-md"
|
||||
>
|
||||
{{ `${modelValue?.length ?? 0} 长度 · ${countWords(modelValue ?? '')} 字符` }}
|
||||
{{ `${modelValue?.length ?? 0} ${t('common.length')} · ${countWords(modelValue ?? '')} ${t('common.words')}` }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { AccordionTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { AccordionHeader, AccordionTrigger } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { ListboxFilterProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { Search } from '@lucide/vue'
|
||||
import { ListboxFilter, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCommand } from '.'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { X } from '@lucide/vue'
|
||||
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import DialogOverlay from './DialogOverlay.vue'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { X } from '@lucide/vue'
|
||||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Circle } from 'lucide-vue-next'
|
||||
import { Circle } from '@lucide/vue'
|
||||
import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { DropdownMenuSubTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { MenubarCheckboxItemEmits, MenubarCheckboxItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { MenubarCheckboxItem, MenubarItemIndicator, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { MenubarRadioItemEmits, MenubarRadioItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Circle } from 'lucide-vue-next'
|
||||
import { Circle } from '@lucide/vue'
|
||||
import { MenubarItemIndicator, MenubarRadioItem, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { MenubarSubTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { MenubarSubTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Minus } from 'lucide-vue-next'
|
||||
import { Minus } from '@lucide/vue'
|
||||
import { Primitive, type PrimitiveProps, useForwardProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<PrimitiveProps>()
|
||||
@@ -7,12 +7,9 @@ const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="pin-input-separator"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot>
|
||||
<Minus />
|
||||
</slot>
|
||||
</Primitive>
|
||||
<Primitive data-slot="pin-input-separator" v-bind="forwardedProps">
|
||||
<slot>
|
||||
<Minus />
|
||||
</slot>
|
||||
</Primitive>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { SelectItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { SelectScrollDownButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { SelectScrollUpButtonProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronUp } from 'lucide-vue-next'
|
||||
import { ChevronUp } from '@lucide/vue'
|
||||
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { SelectTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
43
front/components/ui/sonner/Sonner.vue
Normal file
43
front/components/ui/sonner/Sonner.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ToasterProps } from 'vue-sonner'
|
||||
import 'vue-sonner/style.css'
|
||||
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from '@lucide/vue'
|
||||
import { Toaster as Sonner } from 'vue-sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<ToasterProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sonner
|
||||
:class="cn('toaster group', props.class)"
|
||||
:style="{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
}"
|
||||
v-bind="props"
|
||||
>
|
||||
<template #success-icon>
|
||||
<CircleCheckIcon class="size-4" />
|
||||
</template>
|
||||
<template #info-icon>
|
||||
<InfoIcon class="size-4" />
|
||||
</template>
|
||||
<template #warning-icon>
|
||||
<TriangleAlertIcon class="size-4" />
|
||||
</template>
|
||||
<template #error-icon>
|
||||
<OctagonXIcon class="size-4" />
|
||||
</template>
|
||||
<template #loading-icon>
|
||||
<div>
|
||||
<Loader2Icon class="size-4 animate-spin" />
|
||||
</div>
|
||||
</template>
|
||||
<template #close-icon>
|
||||
<XIcon class="size-4" />
|
||||
</template>
|
||||
</Sonner>
|
||||
</template>
|
||||
1
front/components/ui/sonner/index.ts
Normal file
1
front/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from './Sonner.vue'
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LucideShare, LucideImageMinus, LucideArrowRightLeft, LucideLanguages } from 'lucide-vue-next'
|
||||
import { LucideShare, LucideImageMinus, LucideArrowRightLeft, LucideLanguages } from '@lucide/vue'
|
||||
import useMyAppConfig from '@/composables/useMyAppConfig'
|
||||
import type { FileHandleKey, TextHandleKey } from '../components/Preprocessing/types'
|
||||
import generateRandomColors from '@/lib/generateRandomColors'
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Hinzufügen",
|
||||
"copySuccess": "Erfolgreich kopiert"
|
||||
"copySuccess": "Erfolgreich kopiert",
|
||||
"length": "Länge",
|
||||
"words": "Wörter"
|
||||
},
|
||||
"page": {
|
||||
"upload": {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Add",
|
||||
"copySuccess": "Copy Success"
|
||||
"copySuccess": "Copy Success",
|
||||
"length": "length",
|
||||
"words": "words"
|
||||
},
|
||||
"page": {
|
||||
"upload": {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "Ajouter",
|
||||
"copySuccess": "Copié avec succès"
|
||||
"copySuccess": "Copié avec succès",
|
||||
"length": "longueur",
|
||||
"words": "mots"
|
||||
},
|
||||
"page": {
|
||||
"upload": {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "追加",
|
||||
"copySuccess": "コピーしました"
|
||||
"copySuccess": "コピーしました",
|
||||
"length": "長さ",
|
||||
"words": "文字"
|
||||
},
|
||||
"page": {
|
||||
"upload": {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "추가",
|
||||
"copySuccess": "복사되었습니다"
|
||||
"copySuccess": "복사되었습니다",
|
||||
"length": "길이",
|
||||
"words": "단어"
|
||||
},
|
||||
"page": {
|
||||
"upload": {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "添加",
|
||||
"copySuccess": "复制成功"
|
||||
"copySuccess": "复制成功",
|
||||
"length": "长度",
|
||||
"words": "字符"
|
||||
},
|
||||
"page": {
|
||||
"upload": {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
},
|
||||
"common": {
|
||||
"add": "新增",
|
||||
"copySuccess": "複製成功"
|
||||
"copySuccess": "複製成功",
|
||||
"length": "長度",
|
||||
"words": "字符"
|
||||
},
|
||||
"page": {
|
||||
"upload": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { Toaster } from 'vue-sonner'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
const { locale } = useI18n()
|
||||
await useSeo({ locale: locale.value })
|
||||
const appConfig = useMyAppConfig()
|
||||
@@ -11,9 +11,9 @@ const enableBg = computed(() => appConfig.value?.site_enable_bg ?? true)
|
||||
<GlobalDrawer />
|
||||
<GlobalDayjs />
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
<div class="w-full h-full absolute inset-0 z-[-1] bg-linear-to-bl from-primary/40 to-primary">
|
||||
<img v-if="enableBg" class="w-full h-full object-cover" :src="bgUrl" />
|
||||
</div>
|
||||
<p class="absolute inset-0 z-[-1] bg-linear-to-bl from-primary/40 to-primary">
|
||||
<img v-if="enableBg" class="w-full h-full block object-cover" :src="bgUrl" />
|
||||
</p>
|
||||
<div class="h-full w-full flex flex-col items-center lg:p-10 p-5 overflow-y-auto">
|
||||
<Navbar />
|
||||
<slot />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineNuxtConfig } from 'nuxt/config'
|
||||
import getApiBaseUrl from './lib/getApiBaseUrl'
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
@@ -9,13 +10,13 @@ export default defineNuxtConfig({
|
||||
// '@serwist/nuxt',
|
||||
'@vueuse/nuxt',
|
||||
'motion-v/nuxt',
|
||||
'nuxt-lucide-icons',
|
||||
'shadcn-nuxt',
|
||||
'@vee-validate/nuxt',
|
||||
'@pinia/nuxt',
|
||||
'@nuxt/image',
|
||||
'@nuxtjs/i18n',
|
||||
'vue3-pixi-nuxt',
|
||||
'nuxt-lucide-icons',
|
||||
],
|
||||
// serwist: {},
|
||||
i18n: {
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.16.0",
|
||||
"@nuxt/image": "^2.0.0",
|
||||
"@nuxtjs/i18n": "^10.4.0",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/vue-query": "^5.100.13",
|
||||
"@tanstack/vue-query": "^5.100.12",
|
||||
"@tiptap/extension-blockquote": "^3.23.6",
|
||||
"@tiptap/extension-bold": "^3.23.6",
|
||||
"@tiptap/extension-bubble-menu": "^3.23.6",
|
||||
@@ -37,21 +38,19 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"file-type": "^22.0.1",
|
||||
"filesize": "^10.1.6",
|
||||
"filesize": "^11.0.17",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"heic-to": "^1.4.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lucide-vue-next": "^0.542.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"motion-v": "^1.10.3",
|
||||
"motion-v": "^2.2.1",
|
||||
"nanoid": "^5.1.11",
|
||||
"nuxt": "4.4.2",
|
||||
"nuxt-lucide-icons": "1.0.5",
|
||||
"nuxt": "4.4.6",
|
||||
"nuxt-lucide-icons": "2.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"pixi.js": "^8.18.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reka-ui": "^2.9.8",
|
||||
"shadcn-nuxt": "2.0.1",
|
||||
"sweet-curl-parser": "^1.0.4",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
@@ -59,9 +58,8 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul-vue": "^0.4.1",
|
||||
"vue": "^3.5.34",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-sonner": "^1.3.2",
|
||||
"vue3-pixi": "1.0.0-beta.2"
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vue3-pixi": "^1.0.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"esbuild": "0.25.6"
|
||||
@@ -71,10 +69,11 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"@vueuse/nuxt": "^13.9.0",
|
||||
"typescript": "^5.9.3",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@vueuse/nuxt": "^14.3.0",
|
||||
"shadcn-nuxt": "^2.7.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.7",
|
||||
"vue3-pixi-nuxt": "1.0.0-beta.2"
|
||||
"vue3-pixi-nuxt": "^1.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { LucideAlertCircle } from 'lucide-vue-next'
|
||||
import { LucideAlertCircle } from '@lucide/vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-25
|
||||
@@ -0,0 +1,69 @@
|
||||
## Context
|
||||
|
||||
worker 目前所有檔案清理都依賴 `file:remove` 延遲任務,由業務邏輯在適當時機主動排程(上傳完成後設定 TTL、share 刪除時觸發)。若任務在排程前 worker 重啟、Redis 任務丟失或業務邏輯有 bug,檔案就會永久殘留在磁碟上。`asynq` 已提供 `Scheduler` 元件支援 cron 排程,無需引入新依賴。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 每天凌晨自動掃描並清理三類漏網檔案
|
||||
- 不重複造輪子,清理邏輯複用現有 `file:remove` 任務
|
||||
- 不影響現有任務流程與 API
|
||||
|
||||
**Non-Goals:**
|
||||
- 不處理 `Expire == 0` 的特殊情況
|
||||
- 不清理 `fileShareRelational` 裡的孤兒 share 條目
|
||||
- 不提供手動觸發的 HTTP 介面
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. 使用 `asynq.Scheduler` 而非外部 cron,排程時間為每天凌晨 03:00**
|
||||
|
||||
`asynq.Scheduler` 在同一 worker 程式內以獨立 goroutine 運行,透過 Redis 做 leader election(避免多實例重複觸發)。外部 cron 需要額外基礎設施。選擇 Scheduler,cron 表達式為 `0 3 * * *`。
|
||||
|
||||
```
|
||||
main.go
|
||||
├── asynq.NewServer(...) // 處理任務
|
||||
├── asynq.NewScheduler(...) // 排程 file:janitor @ 03:00
|
||||
└── mux.HandleFunc("file:janitor", tasks.FileJanitor)
|
||||
```
|
||||
|
||||
**2. `FileJanitor` 函式放在 `worker/internal/tasks/file.go`,超過 300 行才拆資料夾**
|
||||
|
||||
目前 `file.go` 為 50 行,加入 janitor 後預計約 100 行,遠低於 300 行門檻,直接寫入 `file.go`。若未來該檔超過 300 行,則改為 `worker/internal/tasks/file/` 目錄,拆成 `janitor.go`(清理邏輯)與 `remove.go`(原 `RemoveFile` 邏輯)。
|
||||
|
||||
**3. Case 1(孤兒本地檔)直接 `os.RemoveAll`,不走 `file:remove`**
|
||||
|
||||
這類檔案在 fileInfoMap 中不存在,`RemoveFile` handler 會直接 return nil(找不到 fileInfo),走 `file:remove` 無法刪除磁碟檔案。必須直接刪除。
|
||||
|
||||
**4. Case 2、3 透過 `SetFileRemoveTask(id, 0)` 委派**
|
||||
|
||||
複用 `RemoveFile` 現有邏輯(含 share 二次確認),避免重複實作。delay=0 表示立即處理。
|
||||
|
||||
**5. 掃描策略:一次性全量讀取**
|
||||
|
||||
- 本地目錄:`os.ReadDir(uploadPath)` 取得所有檔名(即 fileId)
|
||||
- Redis:`GetRedisFileInfoAll()` 一次取得完整 fileInfoMap
|
||||
- 兩者建立 map 後交叉比對,時間複雜度 O(n)
|
||||
|
||||
```
|
||||
本地檔案 set ────┐
|
||||
├─ 差集 → Case 1
|
||||
Redis fileInfoMap ┘
|
||||
|
||||
Redis fileInfoMap ─ 遍歷 ─┬─ type==init && expired → Case 2
|
||||
└─ type==already && no share → Case 3
|
||||
```
|
||||
|
||||
**6. Case 3 的 share 關係查詢**
|
||||
|
||||
對每個 `type==already` 的 fileId 呼叫 `GetRedisFileShareRelational(fileId)`,若回傳空 slice 則觸發刪除。此為 O(n) Redis 查詢,數量級與檔案數相同,凌晨低峰期執行可接受。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **誤刪風險(Case 1)**:本地檔案剛建立但 fileInfoMap 尚未寫入的時間窗口(上傳初始化中)。→ 凌晨執行,正在上傳中的檔案其 init 記錄通常已存在 Redis,風險極低。
|
||||
- **Redis 查詢量(Case 3)**:若檔案數量龐大,逐一查詢 fileShareRelational 會產生大量 Redis 請求。→ 目前是輕量平台,可接受;未來可改用 `HGETALL fileShareRelational` 一次取得再做本地比對。
|
||||
- **Scheduler 單點**:`asynq.Scheduler` 依賴 Redis leader election,Redis 不可用時排程失敗。→ 可接受,Redis 不可用時整個 worker 本就無法運作。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
直接部署,無資料遷移。首次執行會清理歷史殘留檔案,屬於預期行為。
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
現有的檔案清理機制依賴 asynq 延遲任務(`file:remove`),在異常情況下(worker 重啟、任務丟失、Redis 資料不一致)可能導致本地磁碟殘留孤兒檔案,長期累積佔用儲存空間。需要一個每日定期掃描的兜底清理機制。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 `file:janitor` asynq 任務處理函式(`worker/internal/tasks/janitor.go`)
|
||||
- 在 `worker/main.go` 中加入 `asynq.Scheduler`,每天凌晨 00:00 自動排程 `file:janitor`
|
||||
- 在 `worker/main.go` 的 mux 中註冊 `file:janitor` handler
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `file-janitor`: 每日定期掃描並清理三類漏網檔案:本地有但 fileInfoMap 無的孤兒檔、init 狀態已過期的未完成上傳、已完成上傳但無任何 share 關係的孤立檔
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
## Impact
|
||||
|
||||
- **worker/internal/tasks/**:新增 `janitor.go`
|
||||
- **worker/main.go**:新增 `asynq.Scheduler` 及 `file:janitor` handler 註冊
|
||||
- 不影響任何 API 或前端
|
||||
- 無新增外部依賴(`asynq.Scheduler` 已在現有依賴中)
|
||||
@@ -0,0 +1,45 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 每日定期排程 file:janitor 任務
|
||||
系統 SHALL 在每天凌晨 00:00(伺服器本地時間)自動排程一次 `file:janitor` asynq 任務。多個 worker 實例並存時,系統 SHALL 保證同一時間只有一個實例觸發排程(透過 asynq.Scheduler 的 Redis leader election 機制)。
|
||||
|
||||
#### Scenario: 單實例正常排程
|
||||
- **WHEN** worker 啟動且時間到達每天 00:00
|
||||
- **THEN** asynq.Scheduler 將 `file:janitor` 任務加入 asynq 佇列一次
|
||||
|
||||
#### Scenario: 多實例防重複觸發
|
||||
- **WHEN** 多個 worker 實例同時運行且時間到達 00:00
|
||||
- **THEN** 只有一個實例成功排程,其餘實例因 leader election 未獲鎖而跳過
|
||||
|
||||
### Requirement: 清理本地孤兒檔案(無 fileInfoMap 記錄)
|
||||
系統 SHALL 掃描本地上傳目錄,對每個存在於磁碟但在 Redis `fileInfoMap` 中無對應記錄的檔案,直接執行 `os.RemoveAll` 刪除。
|
||||
|
||||
#### Scenario: 刪除孤兒本地檔案
|
||||
- **WHEN** 本地上傳目錄中存在 fileId 目錄,且 Redis fileInfoMap 中無該 fileId 的記錄
|
||||
- **THEN** 系統直接刪除該本地目錄
|
||||
|
||||
#### Scenario: 保留有 fileInfoMap 記錄的檔案
|
||||
- **WHEN** 本地上傳目錄中存在 fileId 目錄,且 Redis fileInfoMap 中有該 fileId 的記錄
|
||||
- **THEN** 系統不刪除該本地目錄,繼續處理下一個
|
||||
|
||||
### Requirement: 清理已過期的 init 狀態檔案
|
||||
系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "init"` 且滿足 `CreatedAt + Expire < 當前 Unix 時間戳` 的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。
|
||||
|
||||
#### Scenario: 排程刪除已過期 init 檔案
|
||||
- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire < now() 的記錄
|
||||
- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列
|
||||
|
||||
#### Scenario: 保留未過期的 init 檔案
|
||||
- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire >= now() 的記錄
|
||||
- **THEN** 系統不排程刪除,繼續處理下一個
|
||||
|
||||
### Requirement: 清理無 share 關係的已完成上傳檔案
|
||||
系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "already"` 且在 Redis `fileShareRelational` 中無任何 share 關係的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。
|
||||
|
||||
#### Scenario: 排程刪除無 share 關係的已完成檔案
|
||||
- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 對應的 shareId 列表為空或不存在
|
||||
- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列
|
||||
|
||||
#### Scenario: 保留有 share 關係的已完成檔案
|
||||
- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 有一個或以上 shareId
|
||||
- **THEN** 系統不排程刪除,繼續處理下一個
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. 新增 FileJanitor 任務處理函式
|
||||
|
||||
- [x] 1.1 在 `worker/internal/tasks/file.go` 末尾新增 `FileJanitor(ctx context.Context, task *asynq.Task) error`(若加入後超過 300 行,則改建 `worker/internal/tasks/file/` 目錄,將 `RemoveFile` 移至 `remove.go`,`FileJanitor` 放至 `janitor.go`)
|
||||
- [x] 1.2 在 `FileJanitor` 中呼叫 `u.GetUploadDirPath()` 取得本地上傳目錄路徑,並用 `os.ReadDir` 掃描目錄取得本地檔案清單
|
||||
- [x] 1.3 呼叫 `models.GetRedisFileInfoAll()` 取得 fileInfoMap 全量資料,建立 fileId set
|
||||
- [x] 1.4 實作 Case 1:本地存在但 fileInfoMap 無記錄的 fileId,呼叫 `os.RemoveAll(filePath)` 直接刪除
|
||||
- [x] 1.5 遍歷 fileInfoMap,實作 Case 2:`FileType == "init"` 且 `CreatedAt + Expire < time.Now().Unix()`,呼叫 `pkgservices.SetFileRemoveTask(fileId, 0)`
|
||||
- [x] 1.6 遍歷 fileInfoMap,實作 Case 3:`FileType == "already"` 且 `GetRedisFileShareRelational(fileId)` 回傳空 slice,呼叫 `pkgservices.SetFileRemoveTask(fileId, 0)`
|
||||
|
||||
## 2. 在 worker/main.go 中整合
|
||||
|
||||
- [x] 2.1 在 `mux` 中註冊 `mux.HandleFunc("file:janitor", tasks.FileJanitor)`
|
||||
- [x] 2.2 建立 `asynq.NewScheduler`,使用與 `asynq.NewServer` 相同的 Redis 連線設定
|
||||
- [x] 2.3 呼叫 `scheduler.Register("0 3 * * *", asynq.NewTask("file:janitor", nil))` 設定每日凌晨 03:00 排程
|
||||
- [x] 2.4 在 `srv.Run(mux)` 之前啟動 `scheduler.Start()`,並在程式結束時呼叫 `scheduler.Shutdown()`
|
||||
|
||||
## 3. 驗證
|
||||
|
||||
- [x] 3.1 在 `worker/` 目錄下執行 `go build ./...`,確認編譯通過
|
||||
45
openspec/specs/file-janitor/spec.md
Normal file
45
openspec/specs/file-janitor/spec.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 每日定期排程 file:janitor 任務
|
||||
系統 SHALL 在每天凌晨 03:00(伺服器本地時間)自動排程一次 `file:janitor` asynq 任務。多個 worker 實例並存時,系統 SHALL 保證同一時間只有一個實例觸發排程(透過 asynq.Scheduler 的 Redis leader election 機制)。
|
||||
|
||||
#### Scenario: 單實例正常排程
|
||||
- **WHEN** worker 啟動且時間到達每天 03:00
|
||||
- **THEN** asynq.Scheduler 將 `file:janitor` 任務加入 asynq 佇列一次
|
||||
|
||||
#### Scenario: 多實例防重複觸發
|
||||
- **WHEN** 多個 worker 實例同時運行且時間到達 03:00
|
||||
- **THEN** 只有一個實例成功排程,其餘實例因 leader election 未獲鎖而跳過
|
||||
|
||||
### Requirement: 清理本地孤兒檔案(無 fileInfoMap 記錄)
|
||||
系統 SHALL 掃描本地上傳目錄,對每個存在於磁碟但在 Redis `fileInfoMap` 中無對應記錄的資料夾,直接執行 `os.RemoveAll` 刪除。臨時上傳資料夾命名格式為 `<fileId>_tmp`,系統 SHALL 將 `_tmp` 後綴去除後再查詢 fileInfoMap,以判斷對應的 fileId 是否存在。
|
||||
|
||||
#### Scenario: 刪除孤兒本地資料夾
|
||||
- **WHEN** 本地上傳目錄中存在名為 `<fileId>` 或 `<fileId>_tmp` 的資料夾,且 Redis fileInfoMap 中無該 fileId 的記錄
|
||||
- **THEN** 系統直接刪除該本地資料夾(保留原始名稱,不去除 `_tmp`)
|
||||
|
||||
#### Scenario: 保留有 fileInfoMap 記錄的資料夾
|
||||
- **WHEN** 本地上傳目錄中存在名為 `<fileId>` 或 `<fileId>_tmp` 的資料夾,且 Redis fileInfoMap 中有該 fileId 的記錄
|
||||
- **THEN** 系統不刪除該資料夾,繼續處理下一個
|
||||
|
||||
### Requirement: 清理已過期的 init 狀態檔案
|
||||
系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "init"` 且滿足 `CreatedAt + Expire < 當前 Unix 時間戳` 的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。
|
||||
|
||||
#### Scenario: 排程刪除已過期 init 檔案
|
||||
- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire < now() 的記錄
|
||||
- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列
|
||||
|
||||
#### Scenario: 保留未過期的 init 檔案
|
||||
- **WHEN** fileInfoMap 中存在 FileType=="init" 且 CreatedAt+Expire >= now() 的記錄
|
||||
- **THEN** 系統不排程刪除,繼續處理下一個
|
||||
|
||||
### Requirement: 清理無 share 關係的已完成上傳檔案
|
||||
系統 SHALL 遍歷 Redis `fileInfoMap`,對 `FileType == "already"` 且在 Redis `fileShareRelational` 中無任何 share 關係的記錄,透過 `SetFileRemoveTask(fileId, 0)` 排程立即刪除。
|
||||
|
||||
#### Scenario: 排程刪除無 share 關係的已完成檔案
|
||||
- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 對應的 shareId 列表為空或不存在
|
||||
- **THEN** 系統呼叫 SetFileRemoveTask(fileId, 0),將 file:remove 任務加入佇列
|
||||
|
||||
#### Scenario: 保留有 share 關係的已完成檔案
|
||||
- **WHEN** fileInfoMap 中存在 FileType=="already" 的記錄,且 fileShareRelational 中該 fileId 有一個或以上 shareId
|
||||
- **THEN** 系統不排程刪除,繼續處理下一個
|
||||
@@ -1,22 +1,52 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
"github.com/redis/rueidis"
|
||||
)
|
||||
|
||||
var rdb rueidis.Client
|
||||
|
||||
const (
|
||||
redisMaxRetries = 10
|
||||
redisBaseDelay = 300 * time.Millisecond
|
||||
redisBackoffFactor = 2.7
|
||||
redisMaxDelay = 15 * time.Second
|
||||
)
|
||||
|
||||
func InitRedis() error {
|
||||
opt, err := rueidis.ParseURL(GetEnv("redis.url"))
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("invalid redis url: %w", err)
|
||||
}
|
||||
client, err := rueidis.NewClient(opt)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
var lastErr error
|
||||
for attempt := range redisMaxRetries {
|
||||
var client rueidis.Client
|
||||
client, lastErr = rueidis.NewClient(opt)
|
||||
if lastErr == nil {
|
||||
rdb = client
|
||||
return nil
|
||||
}
|
||||
if attempt == redisMaxRetries-1 {
|
||||
break
|
||||
}
|
||||
delay := time.Duration(math.Min(
|
||||
float64(redisBaseDelay)*math.Pow(redisBackoffFactor, float64(attempt)),
|
||||
float64(redisMaxDelay),
|
||||
))
|
||||
slog.Warn("redis connection failed, retrying",
|
||||
"attempt", attempt+1,
|
||||
"maxRetries", redisMaxRetries,
|
||||
"retryIn", delay.String(),
|
||||
"error", lastErr,
|
||||
)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
rdb = client
|
||||
return nil
|
||||
return fmt.Errorf("redis connection failed after %d attempts: %w", redisMaxRetries, lastErr)
|
||||
}
|
||||
|
||||
func GetRedisClient() rueidis.Client {
|
||||
|
||||
1863
pnpm-lock.yaml
generated
1863
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
packages:
|
||||
# all packages in direct subdirs of packages/
|
||||
- 'front'
|
||||
- 'pkg/*'
|
||||
# - '*'
|
||||
# all packages in direct subdirs of packages/
|
||||
- 'front'
|
||||
- 'pkg/*'
|
||||
# - '*'
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
maplibre-gl: true
|
||||
sharp: true
|
||||
vue-demi: true
|
||||
'@parcel/watcher': true
|
||||
esbuild: true
|
||||
maplibre-gl: true
|
||||
sharp: true
|
||||
vue-demi: true
|
||||
|
||||
@@ -6,7 +6,10 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"pkg/models"
|
||||
pkgservices "pkg/services"
|
||||
u "pkg/utils"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
@@ -40,6 +43,10 @@ func RemoveFile(ctx context.Context, task *asynq.Task) error {
|
||||
return err
|
||||
}
|
||||
filePath := filepath.Join(uploadPath, payload.FileId)
|
||||
// 如果是临时文件删除文件夹
|
||||
if fileInfo.FileType == models.FileTypeInit {
|
||||
filePath += "_tmp"
|
||||
}
|
||||
if err := rdb.Do(ctx, rdb.B().Hdel().Key("015:fileInfoMap").Field(payload.FileId).Build()).Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -48,3 +55,63 @@ func RemoveFile(ctx context.Context, task *asynq.Task) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FileJanitor(_ context.Context, _ *asynq.Task) error {
|
||||
uploadPath, err := u.GetUploadDirPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(uploadPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allFileInfo, err := models.GetRedisFileInfoAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Case 1: 本地有但 fileInfoMap 無 → 直接刪除
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
fileId := strings.TrimSuffix(name, "_tmp")
|
||||
if _, exists := allFileInfo[fileId]; !exists {
|
||||
if err := os.RemoveAll(filepath.Join(uploadPath, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2 & 3: 遍歷 fileInfoMap
|
||||
now := time.Now().Unix()
|
||||
for fileId, rawInfo := range allFileInfo {
|
||||
var info models.RedisFileInfo
|
||||
if err := json.Unmarshal([]byte(rawInfo), &info); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 2: init 狀態且已過期
|
||||
if info.FileType == models.FileTypeInit && info.CreatedAt+info.Expire < now {
|
||||
if err := pkgservices.SetFileRemoveTask(fileId, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Case 3: 已完成上傳但無 share 關係
|
||||
if info.FileType == models.FileTypeUpload {
|
||||
shareIDs, err := models.GetRedisFileShareRelational(fileId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(shareIDs) == 0 {
|
||||
if err := pkgservices.SetFileRemoveTask(fileId, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"pkg/geoip"
|
||||
"pkg/models"
|
||||
pkgservices "pkg/services"
|
||||
u "pkg/utils"
|
||||
"worker/internal/services"
|
||||
|
||||
@@ -35,21 +34,10 @@ func RemoveShare(ctx context.Context, task *asynq.Task) error {
|
||||
})
|
||||
if len(shareIDs) == 0 {
|
||||
rdb := u.GetRedisClient()
|
||||
uploadPath, err := u.GetUploadDirPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := filepath.Join(uploadPath, payload.FileId)
|
||||
if err := rdb.Do(ctx, rdb.B().Hdel().Key("015:fileShareRelational").Field(payload.FileId).Build()).Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rdb.Do(ctx, rdb.B().Hdel().Key("015:fileInfoMap").Field(payload.FileId).Build()).Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return pkgservices.SetFileRemoveTask(payload.FileId, 0)
|
||||
}
|
||||
if err := models.SetRedisFileShareRelational(payload.FileId, shareIDs); err != nil {
|
||||
return err
|
||||
|
||||
@@ -28,6 +28,10 @@ func main() {
|
||||
logger.Fatal("redis init failed", zap.Error(err))
|
||||
panic(err)
|
||||
}
|
||||
if err := utils.InitAsynq(); err != nil {
|
||||
logger.Fatal("asynq init failed", zap.Error(err))
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := i18n.Init(); err != nil {
|
||||
log.Fatalf("failed to init i18n: %v", err)
|
||||
@@ -46,10 +50,20 @@ func main() {
|
||||
mux.HandleFunc("share:remove", tasks.RemoveShare)
|
||||
mux.HandleFunc("share:notify", tasks.ShareNotify)
|
||||
mux.HandleFunc("file:remove", tasks.RemoveFile)
|
||||
mux.HandleFunc("file:janitor", tasks.FileJanitor)
|
||||
mux.HandleFunc("image:compress", tasks.CompressImage)
|
||||
mux.HandleFunc("image:convert", tasks.ConvertImage)
|
||||
mux.HandleFunc("text:translate", tasks.TranslateText)
|
||||
|
||||
scheduler := asynq.NewScheduler(utils.RedisURI2AsynqOpt(utils.GetEnv("redis.url")), nil)
|
||||
if _, err := scheduler.Register("0 3 * * *", asynq.NewTask("file:janitor", nil)); err != nil {
|
||||
log.Fatalf("could not register scheduler: %v", err)
|
||||
}
|
||||
if err := scheduler.Start(); err != nil {
|
||||
log.Fatalf("could not start scheduler: %v", err)
|
||||
}
|
||||
defer scheduler.Shutdown()
|
||||
|
||||
if err := srv.Run(mux); err != nil {
|
||||
log.Fatalf("could not run server: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user