Skip to content
Open
32 changes: 32 additions & 0 deletions DELIVERY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# 2024 主题寄件功能

访客凭管理员分发的寄件码进入 `/#/delivery`,验证后复用 `SendFileView`、`useSendFlow` 及普通上传服务。上传成功返回普通取件码;文本、单文件和一个多文件 ZIP 均按一次成功分享计次。

## 结构

- `DeliveryManageView` 负责列表与收件页面切换。
- `DeliveryCodeList`、`DeliveryCodeEditor`、`DeliveryCodeShare`、`DeliveryBatchActions` 分别负责列表、创建/编辑、口令分享及批量操作。
- `useDeliveryAdmin` 协调查询,表单和批量状态分别位于 `useDeliveryCodeForm`、`useDeliveryBatch`。
- 收件使用原 `FileManageView`,后端按 `FileCodes.delivery_id` 限定范围;历史私有文件不展示公开取件入口。

## 凭证与上传

管理员与寄件客户端分离,寄件 401 不清除管理员会话。`useDelivery` 在令牌到期前刷新,每个分片请求也检查有效期;并发请求共用一次续期。后台计时器暂停导致令牌过期时,用页面内存里的原口令重新验证。页面销毁清除定时器和凭证,不持久化到本地存储。

`useSendFlow` 与普通发送直接共用完整系统配置;token、remaining 等寄件授权字段不进入站点配置。上传设置和存储目录统一由系统设置控制,寄件码不会覆盖存储方式或指定保存位置。

管理列表不携带口令,点击“查看 / 复制”时请求 `/admin/delivery/codes/{id}/secret`。编辑表单不自动读取旧口令,留空即不修改。后台耗尽寄件码保留并停用,删除采用软删除,收件关联继续有效。

## 接口与主题

依赖后端的验证、续期、管理接口和支持寄件凭证的普通上传接口,以及迁移 013–015。此目录只实现 2024 主题的寄件入口和后台管理;不修改 2023 主题代码、系统设置或 NAS 分享。公共页脚仅保留“凭码寄件”入口,`filecodebox-features=delivery` 标记供后端识别原生主题能力。

## 验证

已在带依赖的临时前端目录执行 `npm run check:architecture`、`npm run type-check`、`npm run build-only`,均通过。构建验证不依赖个人服务器、存储密钥或实际业务数据。

历史缺失原文的寄件码升级后停用,管理员在编辑界面设置新口令后再启用。前端不再提示通过访客验证恢复摘要对应的口令。

发送完成状态不等待自动复制结果,避免后台页面的剪贴板授权一直挂起时锁住发送按钮。回归脚本不包含在本 PR 中。

寄件上传复用文件管理器和普通上传既有的存储选择、目录规则与安全保护;不新增寄件专用存储配置。
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<!doctype html>
<html lang="en">
<head>
<!-- 后端根据功能标记启用原生寄件路由,兼容旧主题渐进更新。 -->
<meta name="filecodebox-features" content="delivery" />
<meta charset="UTF-8" />
<link rel="icon" href="/assets/logo_small.png" />
<meta
Expand Down
17 changes: 16 additions & 1 deletion src/components/common/PageFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@
</router-link>
<span v-else></span>

<!-- 游客上传关闭时仍可通过寄件码进入受限上传流程。 -->
<router-link
to="/delivery"
class="group flex items-center gap-1.5 text-xs font-medium transition-colors sm:gap-2 sm:text-sm"
:class="
isDarkMode ? 'text-zinc-400 hover:text-zinc-100' : 'text-slate-500 hover:text-zinc-950'
"
>
<!-- 保留寄件入口的收件箱图标,与相邻入口的图标样式一致。 -->
<InboxIcon class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
{{ t('delivery.title') }}
</router-link>

<button
type="button"
@click="$emit('toggle-drawer')"
Expand All @@ -34,7 +47,8 @@

<script setup lang="ts">
import { inject } from 'vue'
import { HistoryIcon, SendIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { HistoryIcon, InboxIcon, SendIcon } from 'lucide-vue-next'

interface Props {
linkText?: string
Expand All @@ -49,5 +63,6 @@ interface Emits {
defineProps<Props>()
defineEmits<Emits>()

const { t } = useI18n()
const isDarkMode = inject('isDarkMode')
</script>
128 changes: 128 additions & 0 deletions src/components/delivery/DeliveryBatchActions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { XIcon, CheckIcon, TrashIcon, ClockIcon } from 'lucide-vue-next'
import BaseButton from '@/components/common/BaseButton.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import type { useDeliveryAdmin } from '@/composables'
import { toRefs, reactive } from 'vue'

// 页面状态由父层唯一创建,子组件只负责对应区域。
const props = defineProps<{ state: ReturnType<typeof useDeliveryAdmin> }>()
const {
selectedCount,
hasSelection,
isAllCurrentPageSelected,
showBatchEdit,
batchForm,
toggleCurrentPageSelection,
clearSelection,
batch,
openBatchEdit,
codes,
acting,
pendingDelete
} = toRefs(reactive(props.state))
const { t } = useI18n()
</script>

<template>
<section
v-if="codes.length"
class="theme-panel mb-4 flex flex-col gap-3 rounded-xl border px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<label class="theme-text-strong inline-flex shrink-0 items-center gap-2 text-sm"
><input
type="checkbox"
class="h-4 w-4"
:checked="isAllCurrentPageSelected"
:disabled="acting"
@change="toggleCurrentPageSelection"
/>{{
hasSelection
? t('delivery.selectedCount', { count: selectedCount })
: t('delivery.selectCurrentPage')
}}</label
>
<div class="flex flex-wrap gap-2">
<BaseButton
v-if="hasSelection"
size="sm"
variant="outline"
:disabled="acting"
@click="clearSelection"
><XIcon class="mr-1 h-4 w-4" />{{ t('delivery.clearSelection') }}</BaseButton
><BaseButton
size="sm"
variant="outline"
:disabled="!hasSelection || acting"
@click="batch('enable')"
><CheckIcon class="mr-1 h-4 w-4" />{{ t('delivery.batchEnable') }}</BaseButton
><BaseButton
size="sm"
variant="outline"
:disabled="!hasSelection || acting"
@click="batch('disable')"
>{{ t('delivery.batchDisable') }}</BaseButton
><BaseButton
size="sm"
variant="secondary"
:disabled="!hasSelection || acting"
@click="openBatchEdit"
><ClockIcon class="mr-1 h-4 w-4" />{{ t('delivery.batchEdit') }}</BaseButton
><BaseButton
size="sm"
variant="danger"
:disabled="!hasSelection || acting"
@click="
pendingDelete = {
kind: 'batch',
id: 0,
name: t('delivery.selectedCount', { count: selectedCount })
}
"
><TrashIcon class="mr-1 h-4 w-4" />{{ t('delivery.batchDelete') }}</BaseButton
>
</div>
</section>

<BaseModal
:show="showBatchEdit"
:title="t('delivery.batchEdit')"
:closable="!acting"
:close-on-backdrop="false"
@close="showBatchEdit = false"
>
<p class="theme-text-muted mb-4 text-sm">
{{ t('delivery.batchEditHint', { count: selectedCount }) }}
</p>
<div class="grid gap-4 sm:grid-cols-2">
<label class="delivery-label"
>{{ t('delivery.expiresLocal')
}}<input
v-model="batchForm.expires_at"
type="datetime-local"
step="1"
class="delivery-input"
/></label>
<label class="delivery-label"
>{{ t('delivery.maxUploads')
}}<input
v-model.number="batchForm.max_uploads"
type="number"
min="1"
max="100000"
class="delivery-input"
/></label>
</div>
<template #footer
><BaseButton variant="secondary" :disabled="acting" @click="showBatchEdit = false">{{
t('common.cancel')
}}</BaseButton
><BaseButton :loading="acting" @click="batch('update')">{{
t('common.save')
}}</BaseButton></template
>
</BaseModal>
</template>

<style scoped src="./delivery.css"></style>
117 changes: 117 additions & 0 deletions src/components/delivery/DeliveryCodeEditor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { PlusIcon, PencilIcon } from 'lucide-vue-next'
import BaseButton from '@/components/common/BaseButton.vue'
import BaseModal from '@/components/common/BaseModal.vue'
import type { useDeliveryAdmin } from '@/composables'
import { toRefs, reactive } from 'vue'

// 页面状态由父层唯一创建,子组件只负责对应区域。
const props = defineProps<{ state: ReturnType<typeof useDeliveryAdmin> }>()
const { editing, minimumUploads, creating, showCreate, form, create } = toRefs(
reactive(props.state)
)
const { t } = useI18n()
</script>

<template>
<BaseModal
:show="showCreate"
:title="t(editing ? 'delivery.edit' : 'delivery.create')"
size="lg"
:closable="!creating"
:close-on-backdrop="false"
@close="showCreate = false"
>
<template #header>
<div class="theme-text-strong flex items-center gap-3">
<div class="theme-control rounded-lg p-2">
<PencilIcon v-if="editing" class="h-5 w-5" /><PlusIcon v-else class="h-5 w-5" />
</div>
<h3 class="text-xl font-semibold">
{{ t(editing ? 'delivery.edit' : 'delivery.create') }}
</h3>
</div>
</template>
<!-- 创建与编辑共用字段;未改口令不进入 PUT,兼容历史超长口令。 -->
<form id="delivery-create-form" class="grid gap-4 sm:grid-cols-2" @submit.prevent="create">
<label class="delivery-label"
>{{ t('delivery.name')
}}<input
v-model="form.name"
required
maxlength="100"
:placeholder="t('delivery.namePlaceholder')"
class="delivery-input"
/></label>
<label class="delivery-label"
>{{ t(editing ? 'delivery.code' : 'delivery.customCode')
}}<input
v-model="form.code"
minlength="8"
:maxlength="editing && form.code === (editing.code || '') ? undefined : 32"
:pattern="
editing && form.code === (editing.code || '') ? undefined : '[A-Za-z0-9_-]{8,32}'
"
autocomplete="off"
:placeholder="t(editing ? 'delivery.legacyCode' : 'delivery.autoCode')"
class="delivery-input"
/></label>
<!-- 系统设置统一决定上传方式和保存目录,寄件码不再保存独立位置。 -->
<p class="theme-text-muted text-xs leading-5 sm:col-span-2">
{{ t('delivery.systemStorageHint') }}
</p>
<label class="delivery-label"
>{{ t('delivery.expiresLocal')
}}<input
v-model="form.expires_at"
required
type="datetime-local"
step="1"
class="delivery-input"
/></label>
<label class="delivery-label sm:col-span-2"
>{{ t('delivery.note')
}}<textarea
v-model="form.note"
maxlength="2000"
rows="3"
class="delivery-input resize-y"
:placeholder="t('delivery.notePlaceholder')"
/>
</label>
<label class="delivery-label sm:col-span-2"
>{{ t('delivery.tags')
}}<input
v-model="form.tagsText"
class="delivery-input"
:placeholder="t('delivery.tagsPlaceholder')"
/><span class="theme-text-muted mt-1 text-xs">{{ t('delivery.tagsHint') }}</span></label
>
<label class="delivery-label"
>{{ t('delivery.maxUploads')
}}<input
v-model.number="form.max_uploads"
required
type="number"
:min="minimumUploads"
max="100000"
step="1"
class="delivery-input"
/></label>
<p class="theme-text-muted text-xs leading-5 sm:col-span-2">
{{ t('delivery.editHint') }}
</p>
</form>
<template #footer
><BaseButton variant="secondary" :disabled="creating" @click="showCreate = false">{{
t('common.cancel')
}}</BaseButton
><BaseButton type="submit" form="delivery-create-form" :loading="creating">{{
t(editing ? 'common.save' : 'delivery.create')
}}</BaseButton></template
>
</BaseModal>
</template>

<style scoped src="./delivery.css"></style>
Loading