24 Commits

Author SHA1 Message Date
keven1024
60d62da572 feat(worker): initialize Asynq in main function for task processing 2026-06-03 17:05:10 +08:00
keven1024
3757ed606f fix(publish): remove conditional enablement for edge tag in workflow configuration 2026-06-02 08:59:08 +08:00
keven1024
26f1c52198 feat(config): add SMTP configuration options for email setup in config.example.yaml 2026-06-02 08:58:43 +08:00
keven1024
64f3d2e1d5 Merge pull request #42 from TrapStoner/fix/redis-startup-retry
fix(redis): retry with exponential backoff on startup
2026-06-01 10:56:55 +08:00
TrapStoner
9d125ba9bd fix(redis): retry with exponential backoff on startup
rueidis.NewClient pings Redis immediately; if the container starts
before Redis is ready the backend fatals. Retry up to 10 times with
exponential backoff (300ms → 810ms → 2.2s → … capped at 15s).
2026-05-29 02:58:46 +03:00
keven1024
2e2698e281 feat(janitor): implement daily file janitor task to clean up orphaned files and expired uploads 2026-05-25 13:43:45 +08:00
keven1024
ab0587bd4d feat(tasks): add file janitor task for cleaning up unused files and schedule it 2026-05-25 13:43:30 +08:00
keven1024
ed9d39301f refactor(tasks): streamline file removal logic and enhance temporary file handling 2026-05-25 12:06:11 +08:00
keven
f1e956ad4c Merge pull request 'dev/0.11' (#3) from dev/0.11 into main
Reviewed-on: https://gitea.fudaoyuan.icu/keven/015/pulls/3
2026-05-24 18:21:29 +08:00
keven1024
7793bef944 fix(FileShareResult): set staleTime to Infinity for create-share query to prevent data refetching 2026-05-24 18:20:28 +08:00
keven1024
1fdd05f0ea feat(Tiptap): add internationalization support for word and length display in multiple languages 2026-05-24 18:18:20 +08:00
keven1024
7128a8c329 feat(Toaster): implement Sonner component for enhanced notification functionality and update import path in default layout 2026-05-24 17:55:19 +08:00
keven1024
3cb878b770 chore(dependencies): add nuxt-lucide-icons@2.1.0 to pnpm-lock.yaml and import Button component in BaseCard.vue for enhanced UI functionality 2026-05-24 14:49:01 +08:00
keven1024
05c3504627 feat(AboutBaseInfo): integrate Avatar, Accordion, and MarkdownRender components for enhanced UI functionality 2026-05-24 14:47:48 +08:00
keven1024
9b1ba13ec3 chore(dependencies): add nuxt-lucide-icons package to enhance icon support in the project 2026-05-24 14:43:01 +08:00
keven1024
2a4fac717a chore(dependencies): update TypeScript, @vueuse/core, and @vueuse/nuxt to 6.0.3 and 14.3.0 respectively, and upgrade various packages in package.json and pnpm-lock.yaml for improved compatibility and functionality 2026-05-24 14:37:07 +08:00
keven1024
e897fe1ed3 chore(dependencies): downgrade markdown-it to 14.1.1 in package.json and pnpm-lock.yaml for consistency and compatibility 2026-05-24 14:26:14 +08:00
keven1024
0aae4c2d36 chore(dependencies): downgrade @tanstack/vue-query and @tanstack/query-core to 5.100.13, update tinyexec to 1.1.2, and add markdown-it@14.1.1 in pnpm-lock.yaml for consistency and compatibility 2026-05-24 14:16:39 +08:00
keven1024
549bdd9f68 chore(dependencies): replace lucide-vue-next imports with @lucide/vue across multiple components for consistency and improved dependency management 2026-05-24 14:06:03 +08:00
keven1024
f956130b4f chore(dependencies): replace lucide-vue-next with @lucide/vue in package.json and update lint workflow for improved dependency management 2026-05-24 14:03:49 +08:00
keven1024
218b4cc6ac chore(dependencies): add @lucide/vue@1.16.0 and update @tanstack/vue-query to 5.100.12 in package.json and pnpm-lock.yaml for enhanced functionality 2026-05-24 13:57:07 +08:00
keven1024
64a936835c chore(dependencies): update @tanstack/vue-query to 5.100.14, markdown-it to 14.2.0, and nuxt to 4.4.6 in package.json and pnpm-lock.yaml for improved compatibility and features 2026-05-24 13:12:26 +08:00
keven1024
b8e4bee050 refactor(layout): replace background div with paragraph for improved semantic structure and maintain background image functionality 2026-05-24 13:06:58 +08:00
keven1024
7e90c54b27 chore(docker): add NODE_OPTIONS environment variable to Dockerfile for increased memory allocation during build 2026-05-24 11:41:20 +08:00
56 changed files with 1449 additions and 972 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -5,6 +5,7 @@ WORKDIR /app
FROM front-base AS front-builder
RUN apk add --no-cache gcompat
ENV CI=true
ENV NODE_OPTIONS="--max-old-space-size=4096"
COPY . .
RUN corepack enable pnpm && pnpm i && pnpm --filter=015-front build && pnpm --dir pkg/mail export

View File

@@ -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 # 发送方邮箱密码/授权码

View File

@@ -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()

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
const props = defineProps<{
title?: string
showBackButton?: boolean

View File

@@ -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<{

View File

@@ -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<{

View File

@@ -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<{

View File

@@ -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'

View File

@@ -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()

View File

@@ -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({

View File

@@ -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'

View File

@@ -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'

View File

@@ -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>

View File

@@ -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'

View File

@@ -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 '.'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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>

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View 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>

View File

@@ -0,0 +1 @@
export { default as Toaster } from './Sonner.vue'

View File

@@ -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'

View File

@@ -38,7 +38,9 @@
},
"common": {
"add": "Hinzufügen",
"copySuccess": "Erfolgreich kopiert"
"copySuccess": "Erfolgreich kopiert",
"length": "Länge",
"words": "Wörter"
},
"page": {
"upload": {

View File

@@ -38,7 +38,9 @@
},
"common": {
"add": "Add",
"copySuccess": "Copy Success"
"copySuccess": "Copy Success",
"length": "length",
"words": "words"
},
"page": {
"upload": {

View File

@@ -38,7 +38,9 @@
},
"common": {
"add": "Ajouter",
"copySuccess": "Copié avec succès"
"copySuccess": "Copié avec succès",
"length": "longueur",
"words": "mots"
},
"page": {
"upload": {

View File

@@ -38,7 +38,9 @@
},
"common": {
"add": "追加",
"copySuccess": "コピーしました"
"copySuccess": "コピーしました",
"length": "長さ",
"words": "文字"
},
"page": {
"upload": {

View File

@@ -38,7 +38,9 @@
},
"common": {
"add": "추가",
"copySuccess": "복사되었습니다"
"copySuccess": "복사되었습니다",
"length": "길이",
"words": "단어"
},
"page": {
"upload": {

View File

@@ -38,7 +38,9 @@
},
"common": {
"add": "添加",
"copySuccess": "复制成功"
"copySuccess": "复制成功",
"length": "长度",
"words": "字符"
},
"page": {
"upload": {

View File

@@ -38,7 +38,9 @@
},
"common": {
"add": "新增",
"copySuccess": "複製成功"
"copySuccess": "複製成功",
"length": "長度",
"words": "字符"
},
"page": {
"upload": {

View File

@@ -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 />

View File

@@ -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: {

View File

@@ -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"
}
}

View File

@@ -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'

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-25

View File

@@ -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 需要額外基礎設施。選擇 Schedulercron 表達式為 `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 electionRedis 不可用時排程失敗。→ 可接受Redis 不可用時整個 worker 本就無法運作。
## Migration Plan
直接部署,無資料遷移。首次執行會清理歷史殘留檔案,屬於預期行為。

View File

@@ -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` 已在現有依賴中)

View File

@@ -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** 系統不排程刪除,繼續處理下一個

View File

@@ -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 ./...`,確認編譯通過

View 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** 系統不排程刪除,繼續處理下一個

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}