|
@@ -182,7 +175,6 @@ onMounted(refresh)
t('delivery.viewFiles')
}}
{{ t(item.enabled ? 'delivery.disable' : 'delivery.enable') }}
From d81ab40310b73e8d0f938d1987b373431bf14b90 Mon Sep 17 00:00:00 2001
From: dawnStamp <310845922@qq.com>
Date: Sat, 19 Sep 2026 12:23:40 +0800
Subject: [PATCH 3/8] =?UTF-8?q?feat(=E5=AF=84=E4=BB=B6=E7=AE=A1=E7=90=86):?=
=?UTF-8?q?=20=E6=96=B0=E5=A2=9E=E7=AD=9B=E9=80=89=E6=89=B9=E9=87=8F?=
=?UTF-8?q?=E7=BC=96=E8=BE=91=E5=92=8C=E5=AF=84=E4=BB=B6=E9=93=BE=E6=8E=A5?=
=?UTF-8?q?=E5=88=86=E4=BA=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
新增搜索排序、批量启停和期限次数修改、备注标签及32位寄件码编辑。
创建成功展示寄件链接和二维码,支持自动验证跳转上传;存储选项与系统设置统一,并明确独立有效期和次数规则。
---
DELIVERY.md | 14 +
src/composables/useAdminFiles.ts | 3 +-
src/composables/useDeliveryAdmin.ts | 283 ++++++++++++++-
src/constants/index.ts | 7 +
src/i18n/delivery.ts | 120 ++++++-
src/i18n/locales/en-US.ts | 1 +
src/i18n/locales/zh-CN.ts | 1 +
src/services/delivery.ts | 19 +-
src/types/delivery.ts | 27 ++
src/utils/share-url.ts | 5 +
src/views/DeliveryView.vue | 11 +-
src/views/manage/DeliveryManageView.vue | 454 +++++++++++++++++++++---
src/views/manage/SystemSettingsView.vue | 25 +-
13 files changed, 891 insertions(+), 79 deletions(-)
diff --git a/DELIVERY.md b/DELIVERY.md
index 606f4cb..8e9b55d 100644
--- a/DELIVERY.md
+++ b/DELIVERY.md
@@ -42,3 +42,17 @@ npx --yes pnpm@9.15.9 run dev --host 127.0.0.1
前端 PR:此仓库的 Vue 页面、组合函数、服务层、国际化、原生导航、剪贴板降级和 Windows 架构检查兼容修复。
后端 PR:独立的寄件 API、数据库迁移、存储/配额处理,以及旧页面地址的新旧主题兼容。不要将本机虚拟环境、构建缓存、线上数据、服务器配置或部署备份加入 PR。两个仓库的 Git 提交、推送和 PR 均需单独执行,不由本地构建自动完成。
+
+## 寄件码编辑与分享结果
+
+管理列表提供配置编辑,复用创建表单并回填名称、有效期、总次数、存储和目录;口令可编辑,改动时限定为 8~32 位;历史长码未修改时继续保留。配置通过 `PUT /admin/delivery/codes/{id}` 保存,后端原子校验总次数不小于已用与预占次数之和。
+
+创建成功窗口展示用途、有效期、剩余次数、寄件码、链接及二维码,支持复制和新标签页前往寄件。链接使用 `/#/delivery?code=...`,前端自动验证后复用普通发送页;口令在 hash 内,不随页面请求发送给服务器。
+
+新建寄件码默认跟随系统存储设置,表单只在勾选“自定义存储位置”后展开存储类型和目录。系统模式提交 `storage_type=system`、空 `target_path`;已有寄件码按原类型回填,自定义配置保持不变。后端在每次新上传开始时解析并保存实际位置。
+
+## 管理列表扩展
+
+寄件码列表沿用文件管理的搜索、筛选、选择和批量确认方式,提供关键字、状态、存储、标签及排序条件。批量操作支持启停、删除、期限和次数修改。备注和标签遵守同一公共规则;“查看收件”仍直接使用 FileManageView,不重复实现文件操作。期限文案明确区分上传授权与文件领取期限。
+
+该版本依赖后端迁移 011/012,以及列表筛选、部分更新和 `/admin/delivery/codes/batch` 接口。旧码验证仍兼容最多 64 位,新建或修改口令最多 32 位。
diff --git a/src/composables/useAdminFiles.ts b/src/composables/useAdminFiles.ts
index 63af444..e43dc26 100644
--- a/src/composables/useAdminFiles.ts
+++ b/src/composables/useAdminFiles.ts
@@ -665,8 +665,9 @@ export function useAdminFiles(options: { getDeliveryId?: () => number | undefine
(expiredCount !== null && expiredCount !== undefined && expiredCount >= 0)
const hasExpirationTimeFile =
detail.has_expiration_time ?? Boolean(expiredAt)
+ // 历史文件缺少存储快照时明确提示,不能以当前全站设置冒充原后端。
const storageBackendValue =
- storage?.backend ?? detail.storageBackend ?? detail.storage_backend ?? '-'
+ storage?.backend ?? detail.storageBackend ?? detail.storage_backend ?? t('fileManage.unknownStorageBackend')
const isChunkedStorage = storage?.is_chunked ?? viewItem.isChunkedFile
const statusInsights = detail.status_insights
const statusInsightState =
diff --git a/src/composables/useDeliveryAdmin.ts b/src/composables/useDeliveryAdmin.ts
index 6e2507a..c3c9992 100644
--- a/src/composables/useDeliveryAdmin.ts
+++ b/src/composables/useDeliveryAdmin.ts
@@ -1,11 +1,19 @@
-import { onBeforeUnmount, reactive, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { DeliveryService } from '@/services'
+import { STORAGE_METHOD_OPTIONS } from '@/constants'
import { useAlertStore } from '@/stores/alertStore'
-import type { DeliveryCode, DeliveryFile } from '@/types/delivery'
+import type {
+ DeliveryBatchRequest,
+ DeliveryCode,
+ DeliveryCodeFilters,
+ DeliveryFile,
+ UpdateDeliveryCode
+} from '@/types/delivery'
import { copyToClipboard } from '@/utils/clipboard'
import { savePrivateDeliveryFile } from '@/utils/download-action'
import { getErrorMessage } from '@/utils/common'
+import { buildDeliveryUrl } from '@/utils/share-url'
/** 业务状态与请求留在组合函数中,页面只负责使用上游组件展示。 */
export function useDeliveryAdmin() {
@@ -26,15 +34,66 @@ export function useDeliveryAdmin() {
copyMessage = ref(''),
showCreated = ref(false)
const selected = ref(null),
- pendingDelete = ref<{ kind: 'code' | 'file'; id: number; name: string } | null>(null)
+ pendingDelete = ref<{ kind: 'code' | 'file' | 'batch'; id: number; name: string } | null>(null)
+ // 选择范围只限当前页;切页、刷新和筛选后均会清空,避免误操作其他页。
+ const selectedIds = ref>(new Set())
+ const showBatchEdit = ref(false)
+ const batchForm = reactive({ expires_at: '', max_uploads: null as number | null })
+ // 创建与编辑共用表单,原始记录单独保存,防止输入直接污染列表。
+ const editing = ref(null)
+ const createdItem = ref(null)
+ const createdLink = computed(() => (createdCode.value ? buildDeliveryUrl(createdCode.value) : ''))
+ const minimumUploads = computed(() =>
+ Math.max(1, (editing.value?.used_count ?? 0) + (editing.value?.reserved_count ?? 0))
+ )
+ // 默认隐藏单独存储配置,已有寄件码按原模式回填。
+ const customStorage = ref(false)
const form = reactive({
name: '',
code: '',
storage_type: 'local',
target_path: 'inbox',
expires_at: '',
- max_uploads: 1
+ max_uploads: 1,
+ note: '',
+ tagsText: ''
})
+ const filters = reactive({
+ keyword: '',
+ status: 'all',
+ storage_type: 'all',
+ tag: '',
+ sort_by: 'created_at',
+ sort_order: 'desc'
+ })
+ const selectedCount = computed(() => selectedIds.value.size)
+ const hasSelection = computed(() => selectedCount.value > 0)
+ const isAllCurrentPageSelected = computed(
+ () => codes.value.length > 0 && codes.value.every((item) => selectedIds.value.has(item.id))
+ )
+ const hasActiveFilters = computed(
+ () =>
+ filters.keyword !== '' ||
+ filters.status !== 'all' ||
+ filters.storage_type !== 'all' ||
+ filters.tag !== '' ||
+ filters.sort_by !== 'created_at' ||
+ filters.sort_order !== 'desc'
+ )
+ /** 标签输入与文件管理一致:逗号、换行分隔,去空白和重复。 */
+ const parseTags = (value: string) => {
+ const seen = new Set()
+ return value
+ .split(/[,,\n]/)
+ .map((tag) => tag.trim())
+ .filter((tag) => {
+ const normalized = tag.toLocaleLowerCase()
+ if (!tag || seen.has(normalized)) return false
+ seen.add(normalized)
+ return true
+ })
+ .slice(0, 12)
+ }
let listSequence = 0,
fileSequence = 0
const report = (error: unknown) => {
@@ -48,6 +107,8 @@ export function useDeliveryAdmin() {
)
}
function openCreate() {
+ editing.value = null
+ customStorage.value = false
const expiry = new Date(Date.now() + 7 * 86400000)
expiry.setMinutes(expiry.getMinutes() - expiry.getTimezoneOffset())
Object.assign(form, {
@@ -56,7 +117,9 @@ export function useDeliveryAdmin() {
storage_type: 'local',
target_path: 'inbox',
expires_at: expiry.toISOString().slice(0, 16),
- max_uploads: 1
+ max_uploads: 1,
+ note: '',
+ tagsText: ''
})
showCreate.value = true
}
@@ -64,10 +127,11 @@ export function useDeliveryAdmin() {
const sequence = ++listSequence
loading.value = true
try {
- const result = await DeliveryService.list(page.value)
+ const result = await DeliveryService.list(page.value, filters)
if (sequence !== listSequence) return
codes.value = result.items
total.value = result.total
+ selectedIds.value = new Set()
if (!result.items.length && page.value > 1) page.value--
} catch (error) {
if (sequence === listSequence) report(error)
@@ -107,23 +171,75 @@ export function useDeliveryAdmin() {
async function create() {
if (creating.value) return
const date = new Date(form.expires_at)
- if (!Number.isFinite(date.getTime()) || date.getTime() <= Date.now()) {
+ // datetime-local 精度到秒,比较时也忽略服务端时间的毫秒,避免未编辑却误传期限。
+ const originalExpiry = editing.value ? new Date(editing.value.expires_at) : null
+ if (originalExpiry)
+ originalExpiry.setMinutes(originalExpiry.getMinutes() - originalExpiry.getTimezoneOffset())
+ const expiryChanged =
+ !editing.value || form.expires_at !== originalExpiry?.toISOString().slice(0, 19)
+ if (!Number.isFinite(date.getTime()) || (expiryChanged && date.getTime() <= Date.now())) {
alerts.showAlert(t('delivery.expiredInput'), 'error')
return
}
- if (form.code && !/^[A-Za-z0-9_-]{8,64}$/.test(form.code.trim())) {
+ const codeChanged = !editing.value || form.code.trim() !== (editing.value.code || '')
+ if (codeChanged && form.code && !/^[A-Za-z0-9_-]{8,32}$/.test(form.code.trim())) {
alerts.showAlert(t('delivery.invalidCode'), 'error')
return
}
+ if (
+ !Number.isInteger(form.max_uploads) ||
+ form.max_uploads < minimumUploads.value ||
+ form.max_uploads > 100000
+ ) {
+ alerts.showAlert(t('delivery.invalidLimit', { count: minimumUploads.value }), 'error')
+ return
+ }
+ // 不把历史可读但不再提供配置的类型静默替换成其他存储。
+ if (
+ customStorage.value &&
+ !STORAGE_METHOD_OPTIONS.some((option) => option.value === form.storage_type)
+ ) {
+ alerts.showAlert(t('delivery.unsupportedStorage'), 'error')
+ return
+ }
+ if (form.note.length > 2000 || parseTags(form.tagsText).some((tag) => tag.length > 24)) {
+ alerts.showAlert(t('delivery.invalidMetadata'), 'error')
+ return
+ }
creating.value = true
try {
+ if (editing.value) {
+ const payload: UpdateDeliveryCode = {
+ name: form.name,
+ storage_type: customStorage.value ? form.storage_type : 'system',
+ target_path: customStorage.value ? form.target_path : '',
+ max_uploads: form.max_uploads,
+ note: form.note,
+ tags: parseTags(form.tagsText)
+ }
+ // 未改日期不传给后端,过期记录也可以只维护备注;改动时才提交新时间。
+ if (expiryChanged) payload.expires_at = date.toISOString()
+ if (codeChanged && form.code.trim()) payload.code = form.code.trim()
+ await DeliveryService.update(editing.value.id, payload)
+ showCreate.value = false
+ editing.value = null
+ alerts.showAlert(t('delivery.updated'), 'success')
+ await refresh()
+ return
+ }
const result = await DeliveryService.create({
- ...form,
+ name: form.name,
+ storage_type: customStorage.value ? form.storage_type : 'system',
+ target_path: customStorage.value ? form.target_path : '',
code: form.code.trim(),
- expires_at: date.toISOString()
+ expires_at: date.toISOString(),
+ max_uploads: form.max_uploads,
+ note: form.note,
+ tags: parseTags(form.tagsText)
})
showCreate.value = false
createdCode.value = result.code
+ createdItem.value = result.item
copyMessage.value = ''
showCreated.value = true
if (page.value === 1) await refresh()
@@ -151,7 +267,10 @@ export function useDeliveryAdmin() {
acting.value = true
try {
const item = pendingDelete.value
- if (item.kind === 'code') await DeliveryService.remove(item.id)
+ if (item.kind === 'batch') {
+ await DeliveryService.batch({ ids: Array.from(selectedIds.value), action: 'delete' })
+ clearSelection()
+ } else if (item.kind === 'code') await DeliveryService.remove(item.id)
else await DeliveryService.removeFile(item.id)
pendingDelete.value = null
await refresh()
@@ -187,6 +306,7 @@ export function useDeliveryAdmin() {
function closeCreated() {
showCreated.value = false
createdCode.value = ''
+ createdItem.value = null
copyMessage.value = ''
}
async function download(file: DeliveryFile) {
@@ -201,7 +321,123 @@ export function useDeliveryAdmin() {
acting.value = false
}
}
- watch(page, refresh)
+ // datetime-local 显示本机时间;保存统一转换为携带时区的 ISO 时间。
+ function openEdit(item: DeliveryCode) {
+ editing.value = item
+ customStorage.value = item.storage_type !== 'system'
+ const expiry = new Date(item.expires_at)
+ expiry.setMinutes(expiry.getMinutes() - expiry.getTimezoneOffset())
+ Object.assign(form, {
+ name: item.name,
+ code: item.code || '',
+ storage_type: item.storage_type === 'system' ? 'local' : item.storage_type,
+ target_path: item.target_path || 'inbox',
+ expires_at: expiry.toISOString().slice(0, 19),
+ max_uploads: item.max_uploads,
+ note: item.note || '',
+ tagsText: (item.tags || []).join(', ')
+ })
+ showCreate.value = true
+ }
+ async function copyLink(input: HTMLInputElement | null) {
+ const ok = await copyToClipboard(createdLink.value, { showMsg: false })
+ if (!ok) {
+ input?.focus()
+ input?.select()
+ }
+ copyMessage.value = t(ok ? 'delivery.copied' : 'delivery.manualCopyLink')
+ }
+ function toggleSelection(id: number) {
+ const next = new Set(selectedIds.value)
+ next.has(id) ? next.delete(id) : next.add(id)
+ selectedIds.value = next
+ }
+ function toggleCurrentPageSelection() {
+ selectedIds.value = isAllCurrentPageSelected.value
+ ? new Set()
+ : new Set(codes.value.map((item) => item.id))
+ }
+ function clearSelection() {
+ selectedIds.value = new Set()
+ }
+ async function search() {
+ if (page.value !== 1) {
+ page.value = 1
+ return
+ }
+ await refresh()
+ }
+ async function resetFilters() {
+ Object.assign(filters, {
+ keyword: '',
+ status: 'all',
+ storage_type: 'all',
+ tag: '',
+ sort_by: 'created_at',
+ sort_order: 'desc'
+ })
+ if (page.value !== 1) {
+ page.value = 1
+ return
+ }
+ await refresh()
+ }
+ /** 点击列表标签时复用筛选入口,避免模板拼接多条表达式。 */
+ async function filterByTag(tag: string) {
+ filters.tag = tag
+ await search()
+ }
+ async function batch(action: 'enable' | 'disable' | 'delete' | 'update') {
+ if (acting.value || !hasSelection.value) return
+ const payload: DeliveryBatchRequest = { ids: Array.from(selectedIds.value), action }
+ if (action === 'update') {
+ if (!batchForm.expires_at && batchForm.max_uploads === null) {
+ alerts.showAlert(t('delivery.batchNoFields'), 'warning')
+ return
+ }
+ if (batchForm.expires_at) {
+ const expiresAt = new Date(batchForm.expires_at)
+ if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() <= Date.now()) {
+ alerts.showAlert(t('delivery.expiredInput'), 'error')
+ return
+ }
+ payload.expires_at = expiresAt.toISOString()
+ }
+ if (batchForm.max_uploads !== null) {
+ if (
+ !Number.isInteger(batchForm.max_uploads) ||
+ batchForm.max_uploads < 1 ||
+ batchForm.max_uploads > 100000
+ ) {
+ alerts.showAlert(t('delivery.invalidLimit', { count: 1 }), 'error')
+ return
+ }
+ payload.max_uploads = batchForm.max_uploads
+ }
+ }
+ acting.value = true
+ try {
+ await DeliveryService.batch(payload)
+ clearSelection()
+ showBatchEdit.value = false
+ alerts.showAlert(t('delivery.batchSuccess'), 'success')
+ await refresh()
+ } catch (error) {
+ report(error)
+ } finally {
+ acting.value = false
+ }
+ }
+ function openBatchEdit() {
+ if (hasSelection.value) {
+ Object.assign(batchForm, { expires_at: '', max_uploads: null })
+ showBatchEdit.value = true
+ }
+ }
+ watch(page, () => {
+ clearSelection()
+ void refresh()
+ })
watch(filePage, loadFiles)
onBeforeUnmount(() => {
listSequence++
@@ -209,6 +445,29 @@ export function useDeliveryAdmin() {
createdCode.value = ''
})
return {
+ customStorage,
+ filters,
+ hasActiveFilters,
+ selectedIds,
+ selectedCount,
+ hasSelection,
+ isAllCurrentPageSelected,
+ showBatchEdit,
+ batchForm,
+ editing,
+ createdItem,
+ createdLink,
+ minimumUploads,
+ openEdit,
+ copyLink,
+ toggleSelection,
+ toggleCurrentPageSelection,
+ clearSelection,
+ search,
+ resetFilters,
+ filterByTag,
+ batch,
+ openBatchEdit,
codes,
files,
page,
diff --git a/src/constants/index.ts b/src/constants/index.ts
index 8c0eb46..8a78562 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -101,3 +101,10 @@ export const DEFAULT_CONFIG = {
description: '文件传输工具',
allowed_file_types: ['*'] as string[]
}
+
+// 设置与寄件管理共用可配置的存储方式,避免两个入口的选项和名称发生偏差。
+export const STORAGE_METHOD_OPTIONS = [
+ { value: 'local', label: 'manage.settings.localStorage' },
+ { value: 's3', label: 'manage.settings.s3Storage' },
+ { value: 'webdav', label: 'manage.settings.webdavStorage' }
+] as const
diff --git a/src/i18n/delivery.ts b/src/i18n/delivery.ts
index c4ec3f8..0263dbf 100644
--- a/src/i18n/delivery.ts
+++ b/src/i18n/delivery.ts
@@ -3,7 +3,7 @@ export const deliveryZh = {
title: '凭码寄件',
subtitle: '输入寄件码,向接收方投递文件',
code: '寄件码',
- codePlaceholder: '输入 8 至 64 位寄件码',
+ codePlaceholder: '输入 8 至 32 位寄件码',
verify: '验证寄件码',
change: '更换寄件码',
remaining: '剩余 {count} 次',
@@ -16,15 +16,32 @@ export const deliveryZh = {
manage: '寄件管理',
manageSubtitle: '创建投递口令,管理接收目录与收到的文件',
create: '创建寄件码',
+ edit: '编辑寄件码配置',
+ updated: '寄件码配置已更新',
+ editHint:
+ '寄件码有效期独立于取件有效期。每次成功分享扣 1 次,多文件 ZIP 和文本各算 1 次;存储配置仅影响之后开始的上传。',
+ invalidLimit: '上传总次数须为 {count} 至 100000 的整数',
+ copyLink: '复制寄件链接',
+ link: '寄件链接',
+ goDeliver: '前往寄件',
+ scanDeliver: '扫码进入寄件页面',
+ manualCopyLink: '已选中链接,请按 Ctrl+C(Mac 为 ⌘C)或长按复制。',
name: '用途名称',
namePlaceholder: '例如:项目资料收集',
customCode: '自定义口令(可选)',
autoCode: '留空自动生成安全口令',
+ // 系统模式在每次新上传时读取全站存储设置。
+ followSystem: '跟随系统设置',
+ systemPath: '使用系统文件路径规则',
+ customStorage: '自定义存储位置',
+ followSystemHint:
+ '默认使用设置中的存储方式和文件存储路径。修改系统设置后,新上传自动跟随;已开始上传和已收文件保留原位置。',
+ unsupportedStorage: '请选择受支持的存储方式',
storage: '存储类型',
local: '本地存储',
target: '目标目录',
targetPlaceholder: '例如 inbox/project-a',
- maxUploads: '最多可投递文件数',
+ maxUploads: '最多可寄件次数',
expiresLocal: '有效期至(本机时间)',
configHint: '目录相对于存储根目录。请先配置所选云存储;寄件码到期不会删除已收文件。',
created: '寄件码已创建',
@@ -58,7 +75,44 @@ export const deliveryZh = {
retained: '上传额度耗尽后自动清除寄件码;文件仍按自己的期限和领取次数清理。',
receive: '需要取件?',
expiredInput: '请选择晚于当前时间的有效期',
- invalidCode: '自定义口令须为 8 至 64 位字母、数字、下划线或短横线。',
+ invalidCode: '自定义口令须为 8 至 32 位字母、数字、下划线或短横线。',
+ searchPlaceholder: '搜索名称、口令、备注或标签',
+ all: '全部',
+ tag: '标签筛选',
+ tagPlaceholder: '输入一个标签',
+ filterByTag: '筛选标签 {tag}',
+ note: '备注',
+ notePlaceholder: '记录这个寄件码的用途或处理说明',
+ tags: '标签',
+ tagsPlaceholder: '多个标签用逗号或换行分隔',
+ tagsHint: '最多 12 个标签,每个不超过 24 个字。',
+ invalidMetadata: '备注最多 2000 字;标签最多 12 个且每个不超过 24 个字。',
+ clearFilters: '清空筛选',
+ selectCurrentPage: '全选本页',
+ selectCode: '选择寄件码 {name}',
+ selectedCount: '已选 {count} 项',
+ clearSelection: '清空选择',
+ batchEnable: '批量启用',
+ batchDisable: '批量停用',
+ batchEdit: '批量编辑次数/有效期',
+ batchDelete: '批量删除',
+ batchEditHint: '将修改已选 {count} 个寄件码;留空的字段保持原值。',
+ batchNoFields: '请至少填写有效期或最多可寄件次数。',
+ batchSuccess: '批量操作已完成。',
+ confirmBatchDelete:
+ '所选寄件码将永久删除并停止新上传;已生成的取件码仍按各自有效期和领取次数生效。',
+ sort: {
+ label: '排序字段',
+ order: '排序方式',
+ createdAt: '创建时间',
+ expiresAt: '有效期',
+ name: '名称',
+ code: '口令',
+ usedCount: '已用次数',
+ maxUploads: '最多可寄件次数',
+ desc: '降序',
+ asc: '升序'
+ },
states: {
active: '可投递',
disabled: '已禁用',
@@ -76,7 +130,7 @@ export const deliveryEn = {
title: 'Deliver files',
subtitle: 'Enter a delivery code to send files to the recipient',
code: 'Delivery code',
- codePlaceholder: 'Enter an 8–64 character code',
+ codePlaceholder: 'Enter an 8–32 character code',
verify: 'Verify code',
change: 'Use another code',
remaining: '{count} uploads left',
@@ -89,15 +143,31 @@ export const deliveryEn = {
manage: 'Delivery codes',
manageSubtitle: 'Create upload codes and manage incoming files',
create: 'Create delivery code',
+ edit: 'Edit delivery code settings',
+ updated: 'Delivery code settings updated',
+ editHint:
+ 'Delivery-code expiry is independent from retrieval expiry. Each successful share costs one attempt, including multi-file ZIPs and text. Storage changes apply to uploads started afterwards.',
+ invalidLimit: 'Total uploads must be an integer from {count} to 100000',
+ copyLink: 'Copy delivery link',
+ link: 'Delivery link',
+ goDeliver: 'Go to upload',
+ scanDeliver: 'Scan to upload',
+ manualCopyLink: 'Link selected. Press Ctrl+C (⌘C on Mac), or long-press to copy.',
name: 'Purpose',
namePlaceholder: 'e.g. Project documents',
customCode: 'Custom code (optional)',
autoCode: 'Leave empty to generate a secure code',
+ followSystem: 'Follow system settings',
+ systemPath: 'Use system file path rules',
+ customStorage: 'Custom storage location',
+ followSystemHint:
+ 'Use the storage and file path configured in Settings. New uploads follow setting changes; existing uploads and files keep their location.',
+ unsupportedStorage: 'Choose a supported storage method',
storage: 'Storage',
local: 'Local storage',
target: 'Target directory',
targetPlaceholder: 'e.g. inbox/project-a',
- maxUploads: 'Maximum number of files',
+ maxUploads: 'Maximum delivery attempts',
expiresLocal: 'Valid until (local time)',
configHint:
'Directory is relative to the storage root. Configure cloud storage first. Expired codes do not delete received files.',
@@ -135,7 +205,45 @@ export const deliveryEn = {
'Delivery codes are removed when upload credits are exhausted. Shared files retain their own expiry and download limits.',
receive: 'Need to collect files?',
expiredInput: 'Choose a future expiry time',
- invalidCode: 'Use 8–64 letters, numbers, underscores or hyphens for a custom code.',
+ invalidCode: 'Use 8–32 letters, numbers, underscores or hyphens for a custom code.',
+ searchPlaceholder: 'Search name, code, note, or tag',
+ all: 'All',
+ tag: 'Tag',
+ tagPlaceholder: 'Enter a tag',
+ filterByTag: 'Filter by tag {tag}',
+ note: 'Note',
+ notePlaceholder: 'Describe this delivery code',
+ tags: 'Tags',
+ tagsPlaceholder: 'Separate tags with commas or new lines',
+ tagsHint: 'Up to 12 tags, 24 characters each.',
+ invalidMetadata:
+ 'Notes allow up to 2000 characters; use at most 12 tags with 24 characters each.',
+ clearFilters: 'Clear filters',
+ selectCurrentPage: 'Select page',
+ selectCode: 'Select delivery code {name}',
+ selectedCount: '{count} selected',
+ clearSelection: 'Clear selection',
+ batchEnable: 'Enable selected',
+ batchDisable: 'Disable selected',
+ batchEdit: 'Edit attempts / expiry',
+ batchDelete: 'Delete selected',
+ batchEditHint: 'Update {count} selected delivery codes. Empty fields stay unchanged.',
+ batchNoFields: 'Enter an expiry or maximum delivery attempts.',
+ batchSuccess: 'Batch operation completed.',
+ confirmBatchDelete:
+ 'Selected delivery codes will be permanently deleted. Existing retrieval codes retain their own expiry and download limits.',
+ sort: {
+ label: 'Sort by',
+ order: 'Order',
+ createdAt: 'Created',
+ expiresAt: 'Expiry',
+ name: 'Name',
+ code: 'Code',
+ usedCount: 'Used count',
+ maxUploads: 'Maximum attempts',
+ desc: 'Descending',
+ asc: 'Ascending'
+ },
states: {
active: 'Active',
disabled: 'Disabled',
diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts
index 387bf47..0c897e4 100644
--- a/src/i18n/locales/en-US.ts
+++ b/src/i18n/locales/en-US.ts
@@ -120,6 +120,7 @@ export default {
}
},
fileManage: {
+ unknownStorageBackend: 'Unknown (storage was not recorded)',
title: 'File Management'
},
settings: {
diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts
index c8a57c0..510fb6d 100644
--- a/src/i18n/locales/zh-CN.ts
+++ b/src/i18n/locales/zh-CN.ts
@@ -120,6 +120,7 @@ export default {
}
},
fileManage: {
+ unknownStorageBackend: '未知(历史记录未保存存储方式)',
title: '文件管理'
},
settings: {
diff --git a/src/services/delivery.ts b/src/services/delivery.ts
index 2328429..fe94230 100644
--- a/src/services/delivery.ts
+++ b/src/services/delivery.ts
@@ -2,10 +2,13 @@ import api, { publicApiClient, rawApiClient } from './client'
import type { ApiResponse } from '@/types'
import type {
CreateDeliveryCode,
+ DeliveryBatchRequest,
DeliveryCode,
+ DeliveryCodeFilters,
DeliveryFile,
DeliveryList,
- DeliverySession
+ DeliverySession,
+ UpdateDeliveryCode
} from '@/types/delivery'
function detail(response: ApiResponse): T {
@@ -22,10 +25,10 @@ export const DeliveryService = {
})
)
},
- async list(page: number) {
+ async list(page: number, filters: DeliveryCodeFilters) {
return detail(
await api.get>>('/admin/delivery/codes', {
- params: { page, page_size: 20 }
+ params: { page, page_size: 20, ...filters }
})
)
},
@@ -54,6 +57,16 @@ export const DeliveryService = {
async removeFile(id: number) {
await api.delete(`/admin/delivery/files/${id}`)
},
+ // 未变更口令时省略 code,兼容历史遗留的 32 位以上口令。
+ async update(id: number, data: UpdateDeliveryCode) {
+ return detail(
+ await api.put>(`/admin/delivery/codes/${id}`, data)
+ )
+ },
+ /** 批量接口由后端原子执行,前端只在成功后清空当前页选择。 */
+ async batch(data: DeliveryBatchRequest) {
+ await api.post('/admin/delivery/codes/batch', data)
+ },
async download(id: number) {
return rawApiClient.get(`/admin/delivery/files/${id}/download`, {
responseType: 'blob',
diff --git a/src/types/delivery.ts b/src/types/delivery.ts
index 39fe09a..97ceb61 100644
--- a/src/types/delivery.ts
+++ b/src/types/delivery.ts
@@ -15,6 +15,9 @@ export interface DeliveryCode {
deleted: boolean
status: string
created_at: string
+ /** 管理侧备注与标签仅用于检索和识别,不影响寄件授权。 */
+ note?: string
+ tags?: string[]
}
export interface DeliveryFile {
retrieval_code?: string | null
@@ -46,6 +49,30 @@ export interface CreateDeliveryCode {
target_path: string
expires_at: string
max_uploads: number
+ note?: string
+ tags?: string[]
+}
+
+/** 寄件管理列表的后端筛选参数,全部使用明确的 all 表示未筛选。 */
+export interface DeliveryCodeFilters {
+ keyword: string
+ status: 'all' | 'active' | 'disabled' | 'expired' | 'exhausted'
+ storage_type: 'all' | 'system' | 'local' | 's3' | 'webdav'
+ tag: string
+ sort_by: 'created_at' | 'expires_at' | 'name' | 'code' | 'used_count' | 'max_uploads'
+ sort_order: 'asc' | 'desc'
+}
+
+/** 单条更新允许省略未改的口令,保证历史超长口令仍可保留。 */
+export interface UpdateDeliveryCode extends Partial> {
+ code?: string
+}
+
+export interface DeliveryBatchRequest {
+ ids: number[]
+ action: 'enable' | 'disable' | 'delete' | 'update'
+ expires_at?: string
+ max_uploads?: number
}
export interface DeliveryList {
items: T[]
diff --git a/src/utils/share-url.ts b/src/utils/share-url.ts
index b361ab5..774d9c0 100644
--- a/src/utils/share-url.ts
+++ b/src/utils/share-url.ts
@@ -36,3 +36,8 @@ export function buildSentRecordQrValue(record: { retrieveCode: string }): string
export function buildWgetCommand(retrieveCode: string, fileName: string): string {
return `wget ${buildAbsoluteUrl(`/share/select?code=${retrieveCode}`)} -O "${fileName}"`
}
+
+/** 寄件口令放在前端路由片段中,访问页面时不会进入服务器 URL 日志。 */
+export function buildDeliveryUrl(code: string): string {
+ return `${window.location.origin}${import.meta.env.BASE_URL}#/delivery?code=${encodeURIComponent(code)}`
+}
diff --git a/src/views/DeliveryView.vue b/src/views/DeliveryView.vue
index ed318f7..2ad52e8 100644
--- a/src/views/DeliveryView.vue
+++ b/src/views/DeliveryView.vue
@@ -1,6 +1,7 @@
diff --git a/src/views/manage/DeliveryManageView.vue b/src/views/manage/DeliveryManageView.vue
index 212d959..660af7c 100644
--- a/src/views/manage/DeliveryManageView.vue
+++ b/src/views/manage/DeliveryManageView.vue
@@ -1,14 +1,22 @@
@@ -76,15 +116,152 @@ onMounted(refresh)
{{ t('delivery.manageSubtitle') }}
-
+
{{ t('fileManage.refresh') }}
+
+
+
-
+ |
{{ t(loading ? 'common.loading' : 'delivery.empty') }}
@@ -120,9 +297,36 @@ onMounted(refresh)
|
|