Skip to content

Commit 3075ff1

Browse files
committed
feat: add admin local share page for NAS files
Browse data/local including subfolders and mint extract codes without re-uploading.
1 parent c32466a commit 3075ff1

10 files changed

Lines changed: 513 additions & 0 deletions

File tree

‎src/composables/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ export { usePublicConfigBootstrap } from './usePublicConfigBootstrap'
99
export { useRetrieveFlow } from './useRetrieveFlow'
1010
export { useRouteLoading } from './useRouteLoading'
1111
export { useSendFlow } from './useSendFlow'
12+
export { useLocalShare } from './useLocalShare'
1213
export { useSystemConfig } from './useSystemConfig'
1314
export { useTheme } from './useTheme'

‎src/composables/useLocalShare.ts‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { computed, ref } from 'vue'
2+
import { useI18n } from 'vue-i18n'
3+
import { FileService } from '@/services'
4+
import { useAlertStore } from '@/stores/alertStore'
5+
import { useConfigStore } from '@/stores/configStore'
6+
import type { LocalShareItem, LocalShareResult } from '@/types'
7+
import { copyToClipboard } from '@/utils/clipboard'
8+
import { getErrorMessage } from '@/utils/common'
9+
import { buildRetrieveUrl } from '@/utils/share-url'
10+
11+
export function useLocalShare() {
12+
const { t } = useI18n()
13+
const alertStore = useAlertStore()
14+
const configStore = useConfigStore()
15+
16+
const currentPath = ref('')
17+
const items = ref<LocalShareItem[]>([])
18+
const truncated = ref(false)
19+
const isLoading = ref(false)
20+
const isSharing = ref(false)
21+
const deletingPath = ref('')
22+
const showShareModal = ref(false)
23+
const sharingItem = ref<LocalShareItem | null>(null)
24+
const shareResult = ref<LocalShareResult | null>(null)
25+
const expireStyle = ref('day')
26+
const expireValue = ref(1)
27+
28+
const expireOptions = computed(() => {
29+
const styles = configStore.config.expire_style?.length
30+
? configStore.config.expire_style
31+
: ['day', 'hour', 'minute', 'forever', 'count']
32+
return styles.map((value) => ({
33+
value,
34+
label: t(`send.time.${value}`)
35+
}))
36+
})
37+
38+
const breadcrumbs = computed(() => {
39+
if (!currentPath.value) return []
40+
const parts = currentPath.value.split('/').filter(Boolean)
41+
return parts.map((name, index) => ({
42+
name,
43+
path: parts.slice(0, index + 1).join('/')
44+
}))
45+
})
46+
47+
const shareModalTitle = computed(() =>
48+
shareResult.value ? t('admin.localShare.resultTitle') : t('admin.localShare.shareTitle')
49+
)
50+
51+
const loadDirectory = async (path: string) => {
52+
isLoading.value = true
53+
try {
54+
const response = await FileService.listLocalFiles(path)
55+
if (response.code !== 200 || !response.detail) {
56+
throw new Error(t('admin.localShare.loadFailed'))
57+
}
58+
currentPath.value = response.detail.path || ''
59+
items.value = response.detail.items || []
60+
truncated.value = Boolean(response.detail.truncated)
61+
} catch (error) {
62+
alertStore.showAlert(getErrorMessage(error, t('admin.localShare.loadFailed')), 'error')
63+
} finally {
64+
isLoading.value = false
65+
}
66+
}
67+
68+
const openShare = (item: LocalShareItem) => {
69+
sharingItem.value = item
70+
shareResult.value = null
71+
expireStyle.value = expireOptions.value[0]?.value || 'day'
72+
expireValue.value = 1
73+
showShareModal.value = true
74+
}
75+
76+
const closeShareModal = () => {
77+
showShareModal.value = false
78+
sharingItem.value = null
79+
shareResult.value = null
80+
}
81+
82+
const confirmShare = async () => {
83+
if (!sharingItem.value) return
84+
isSharing.value = true
85+
try {
86+
const response = await FileService.shareLocalFile({
87+
filename: sharingItem.value.path,
88+
expire_value: expireStyle.value === 'forever' ? 1 : Math.max(1, Number(expireValue.value) || 1),
89+
expire_style: expireStyle.value
90+
})
91+
if (response.code !== 200 || !response.detail?.code) {
92+
throw new Error(t('admin.localShare.shareFailed'))
93+
}
94+
shareResult.value = response.detail
95+
alertStore.showAlert(
96+
t('admin.localShare.shareSuccess', { code: response.detail.code }),
97+
'success'
98+
)
99+
} catch (error) {
100+
alertStore.showAlert(getErrorMessage(error, t('admin.localShare.shareFailed')), 'error')
101+
} finally {
102+
isSharing.value = false
103+
}
104+
}
105+
106+
const copyCode = async () => {
107+
if (!shareResult.value) return
108+
await copyToClipboard(shareResult.value.code, {
109+
notify: (message, type) => alertStore.showAlert(message, type)
110+
})
111+
}
112+
113+
const copyLink = async () => {
114+
if (!shareResult.value) return
115+
await copyToClipboard(buildRetrieveUrl(shareResult.value.code), {
116+
notify: (message, type) => alertStore.showAlert(message, type)
117+
})
118+
}
119+
120+
const removeFile = async (item: LocalShareItem) => {
121+
if (!window.confirm(t('admin.localShare.deleteConfirm', { name: item.path }))) {
122+
return
123+
}
124+
deletingPath.value = item.path
125+
try {
126+
const response = await FileService.deleteLocalFile(item.path)
127+
if (response.code !== 200) {
128+
throw new Error(t('admin.localShare.deleteFailed'))
129+
}
130+
alertStore.showAlert(t('admin.localShare.deleteSuccess'), 'success')
131+
await loadDirectory(currentPath.value)
132+
} catch (error) {
133+
alertStore.showAlert(getErrorMessage(error, t('admin.localShare.deleteFailed')), 'error')
134+
} finally {
135+
deletingPath.value = ''
136+
}
137+
}
138+
139+
return {
140+
breadcrumbs,
141+
currentPath,
142+
deletingPath,
143+
expireOptions,
144+
expireStyle,
145+
expireValue,
146+
isLoading,
147+
isSharing,
148+
items,
149+
shareModalTitle,
150+
shareResult,
151+
sharingItem,
152+
showShareModal,
153+
truncated,
154+
closeShareModal,
155+
confirmShare,
156+
copyCode,
157+
copyLink,
158+
loadDirectory,
159+
openShare,
160+
removeFile
161+
}
162+
}

‎src/constants/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const ROUTES = {
7070
LOGIN: '/login',
7171
DASHBOARD: '/admin/dashboard',
7272
FILE_MANAGE: '/admin/files',
73+
LOCAL_SHARE: '/admin/local',
7374
SETTINGS: '/admin/settings'
7475
} as const
7576

@@ -80,6 +81,7 @@ export const ROUTE_NAMES = {
8081
LOGIN: 'Login',
8182
DASHBOARD: 'Dashboard',
8283
FILE_MANAGE: 'FileManage',
84+
LOCAL_SHARE: 'LocalShare',
8385
SETTINGS: 'Settings'
8486
} as const
8587

‎src/i18n/locales/en-US.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,36 @@ export default {
120120
fileManage: {
121121
title: 'File Management'
122122
},
123+
localShare: {
124+
title: 'Local Share',
125+
subtitle: 'Browse NAS files in data/local and mint extract codes without copying them into FileCodeBox storage.',
126+
mountHint: 'Mount your NAS folder to /app/data/local. Example: -v /volume1/share:/app/data/local:ro',
127+
refresh: 'Refresh',
128+
root: 'Root',
129+
empty: 'No files or folders in this directory',
130+
truncated: 'Too many entries; showing the first {count}',
131+
name: 'Name',
132+
size: 'Size',
133+
ctime: 'Time',
134+
typeDir: 'Folder',
135+
typeFile: 'File',
136+
share: 'Create code',
137+
delete: 'Delete',
138+
deleteConfirm: 'Delete local file “{name}”? This removes the original file under data/local.',
139+
shareTitle: 'Share local file',
140+
shareHint: 'The original file stays on the NAS. Expiring or deleting the code will not delete it.',
141+
expire: 'Expiration',
142+
expireValue: 'Value',
143+
resultTitle: 'Extract code created',
144+
copyCode: 'Copy code',
145+
copyLink: 'Copy retrieve link',
146+
openFiles: 'Open share records',
147+
loadFailed: 'Failed to load local directory',
148+
shareFailed: 'Failed to create extract code',
149+
shareSuccess: 'Created extract code {code}',
150+
deleteSuccess: 'Local file deleted',
151+
deleteFailed: 'Delete failed'
152+
},
123153
settings: {
124154
title: 'System Settings',
125155
basicSettings: 'General',

‎src/i18n/locales/zh-CN.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,36 @@ export default {
120120
fileManage: {
121121
title: '文件管理'
122122
},
123+
localShare: {
124+
title: '本地分享',
125+
subtitle: '浏览 data/local 里的 NAS 文件,直接生成提取码,不会复制或占用快递柜存储。',
126+
mountHint: '把 NAS 目录挂到容器的 /app/data/local 即可在此浏览。例如:-v /volume1/share:/app/data/local:ro',
127+
refresh: '刷新',
128+
root: '根目录',
129+
empty: '当前目录没有可分享的文件或文件夹',
130+
truncated: '条目过多,仅显示前 {count} 项',
131+
name: '名称',
132+
size: '大小',
133+
ctime: '时间',
134+
typeDir: '文件夹',
135+
typeFile: '文件',
136+
share: '生成提取码',
137+
delete: '删除',
138+
deleteConfirm: '确定删除本地文件“{name}”?这会删除 data/local 中的原文件。',
139+
shareTitle: '分享本地文件',
140+
shareHint: '分享后原文件仍留在 NAS 目录。提取码过期或删除不会删除原文件。',
141+
expire: '有效期',
142+
expireValue: '数值',
143+
resultTitle: '提取码已生成',
144+
copyCode: '复制提取码',
145+
copyLink: '复制取件链接',
146+
openFiles: '查看分享记录',
147+
loadFailed: '本地目录加载失败',
148+
shareFailed: '生成提取码失败',
149+
shareSuccess: '已生成提取码 {code}',
150+
deleteSuccess: '已删除本地文件',
151+
deleteFailed: '删除失败'
152+
},
123153
settings: {
124154
title: '系统设置',
125155
basicSettings: '基本设置',

‎src/layout/AdminLayout/AdminLayout.vue‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
MenuIcon,
9191
XIcon,
9292
FolderIcon,
93+
HardDriveIcon,
9394
CogIcon,
9495
LayoutDashboardIcon,
9596
LogOutIcon
@@ -123,6 +124,12 @@ const menuItems: MenuItem[] = [
123124
icon: FolderIcon,
124125
redirect: ROUTES.FILE_MANAGE
125126
},
127+
{
128+
id: ROUTE_NAMES.LOCAL_SHARE,
129+
name: t('admin.localShare.title'),
130+
icon: HardDriveIcon,
131+
redirect: ROUTES.LOCAL_SHARE
132+
},
126133
{
127134
id: ROUTE_NAMES.SETTINGS,
128135
name: t('admin.settings.title'),

‎src/router/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ const routes: RouteRecordRaw[] = [
5959
title: 'files'
6060
}
6161
},
62+
{
63+
path: 'local',
64+
name: ROUTE_NAMES.LOCAL_SHARE,
65+
component: () => import('@/views/manage/LocalShareView.vue'),
66+
meta: {
67+
...adminPageMeta,
68+
title: 'local'
69+
}
70+
},
6271
{
6372
path: 'settings',
6473
name: ROUTE_NAMES.SETTINGS,

‎src/services/file.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import type {
2424
FileInfo,
2525
FileListResponse,
2626
FileUploadResponse,
27+
LocalShareListResponse,
28+
LocalShareResult,
2729
ShareMetadataResponse,
2830
ShareSelectResponse,
2931
TextSendResponse,
@@ -269,4 +271,22 @@ export class FileService {
269271
}
270272
})
271273
}
274+
275+
static async listLocalFiles(path = ''): Promise<ApiResponse<LocalShareListResponse>> {
276+
return api.get('/admin/local/lists', { params: { path } })
277+
}
278+
279+
static async shareLocalFile(payload: {
280+
filename: string
281+
expire_value: number
282+
expire_style: string
283+
}): Promise<ApiResponse<LocalShareResult>> {
284+
return api.post('/admin/local/share', payload)
285+
}
286+
287+
static async deleteLocalFile(filename: string): Promise<ApiResponse<string>> {
288+
return api.delete('/admin/local/delete', {
289+
data: { filename }
290+
})
291+
}
272292
}

‎src/types/file.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,3 +459,26 @@ export interface ChunkUploadCompleteRequest {
459459
}
460460

461461
export type ChunkUploadResponse = null
462+
463+
export interface LocalShareItem {
464+
file: string
465+
name: string
466+
path: string
467+
type: 'file' | 'dir'
468+
ctime: string | null
469+
size: number | null
470+
}
471+
472+
export interface LocalShareListResponse {
473+
path: string
474+
parent: string
475+
truncated: boolean
476+
items: LocalShareItem[]
477+
}
478+
479+
export interface LocalShareResult {
480+
code: string
481+
name: string
482+
path?: string
483+
id?: number
484+
}

0 commit comments

Comments
 (0)