26.9(安全审计修复版)

Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务:

- 安全审计全部修复(docs/security-audit-2026-09-05.md):
  bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、
  全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、
  管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、
  会话默认 7 天、限流缓存故障降级、robots.txt 端点等
- 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固
- Redis 支持库号(FCB_REDIS_DB / redis://…/db URL)
- 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、
  提码 5 位起、chunk 32MiB 上限、admin 审计动作等)

验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
2026-09-05 04:22:41 +08:00
commit 7f060dd0e4
173 changed files with 32455 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
<script setup lang="ts">
/**
* App 根组件:
* - 需求②:Naive UI 主题(darkTheme 按 resolved 传递)+ 全局主题令牌
* - 需求⑤:背景图(config.background_url → 固定层 + 半透明遮罩)
* - 需求⑦:notify_enabled 时右上角玻璃悬浮通知(localStorage 记忆已读)
* - 需求⑩:i18n 装配后同步 <html lang>
*/
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { NConfigProvider, darkTheme } from 'naive-ui'
import { useConfigStore } from '@/stores/config'
import { useTheme } from '@/theme'
import { currentLocale, i18n } from '@/i18n'
import ToastHost from '@/components/ToastHost.vue'
import NotifyPop from '@/components/NotifyPop.vue'
const config = useConfigStore()
const route = useRoute()
const { resolved } = useTheme()
// 语言切换时同步 <html lang>(初始由 i18n 模块设置一次)
watch(
currentLocale,
(loc) => {
document.documentElement.lang = loc
},
{ immediate: true },
)
// document.title = i18n(路由标题键) · 站点名(需求⑩:语言/路由/站点名三者联动)
watch(
[() => route.fullPath, currentLocale, () => config.displayName] as const,
() => {
const key = route.meta.titleKey
const title = typeof key === 'string' ? i18n.global.t(key) : config.displayName
document.title = `${title} · ${config.displayName}`
},
{ immediate: true },
)
const naiveTheme = computed(() => (resolved.value === 'dark' ? darkTheme : null))
const themeOverrides = computed(() =>
resolved.value === 'dark'
? {
common: {
primaryColor: '#7d95ff',
primaryColorHover: '#98abff',
primaryColorPressed: '#6c86f5',
primaryColorSuppl: '#98abff',
},
}
: {
common: {
primaryColor: '#4f6ef7',
primaryColorHover: '#3d5bf0',
primaryColorPressed: '#4359e0',
primaryColorSuppl: '#3d5bf0',
},
},
)
const hasBackground = computed(() => Boolean(config.backgroundUrl.trim()))
// —— 需求⑦:通知关闭记忆 ——
const NOTIFY_READ_KEY = 'fcb_notify_read'
const notifyOpen = ref(false)
const notifyRead = ref(false)
function notifyFingerprint(): string {
return `${config.notifyEnabled}|${config.notifyTitle}|${config.notifyContent}`
}
function restoreNotifyRead(): void {
try {
notifyRead.value = localStorage.getItem(NOTIFY_READ_KEY) === notifyFingerprint()
} catch {
notifyRead.value = false
}
}
function dismissNotify(): void {
notifyOpen.value = false
notifyRead.value = true
try {
localStorage.setItem(NOTIFY_READ_KEY, notifyFingerprint())
} catch {
/* ignore */
}
}
// 配置加载完成后:内容有更新则重新弹出(指纹变化视为新通知)
watch(
() => [config.loaded, notifyFingerprint()] as const,
() => {
const prev = notifyRead.value
restoreNotifyRead()
if (!prev && notifyRead.value) return // 同一指纹,保持已读
if (config.loaded && config.notifyEnabled && config.notifyContent.trim() && !notifyRead.value) {
notifyOpen.value = true
}
},
)
// 语言/主题切换不重置通知;仅手动关闭
onMounted(() => {
void config.load()
restoreNotifyRead()
if (config.loaded && config.notifyEnabled && config.notifyContent.trim() && !notifyRead.value) {
notifyOpen.value = true
}
})
</script>
<template>
<NConfigProvider
:theme="naiveTheme"
:theme-overrides="themeOverrides"
inline-theme-disabled
>
<div class="app-root">
<!-- 需求⑤环境光影渐变层始终存在+ 背景图固定层 + 半透明遮罩 -->
<div class="app-ambient" aria-hidden="true"></div>
<div
v-if="hasBackground"
class="app-bg"
aria-hidden="true"
:style="{ backgroundImage: `url(${config.backgroundUrl})` }"
></div>
<div v-if="hasBackground" class="app-bg-tint" aria-hidden="true"></div>
<NotifyPop v-if="notifyOpen" :title="config.notifyTitle" :content="config.notifyContent" @close="dismissNotify" />
<ToastHost />
<RouterView />
</div>
</NConfigProvider>
</template>
<style scoped>
.app-root {
display: flex;
flex-direction: column;
min-height: 100vh;
}
</style>
+157
View File
@@ -0,0 +1,157 @@
/** 管理后台 API(对齐 t3 契约:login/verify/logout/file list/update/delete/batch-delete/config/audit */
import { request } from './http'
import { paths } from './paths'
import type {
AdminConfig,
AdminFileListResult,
AdminFileUpdatePayload,
AdminLoginResult,
AuditListResult,
} from './types'
import { pick } from '@/utils/format'
/** 管理员登录(密码错误 → 401 ApiError */
export function adminLogin(password: string): Promise<AdminLoginResult> {
return request<AdminLoginResult>(paths.adminLogin, { method: 'POST', json: { password } })
}
export function adminVerify(): Promise<Record<string, unknown>> {
return request<Record<string, unknown>>(paths.adminVerify)
}
export async function adminLogout(): Promise<void> {
await request<void>(paths.adminLogout, { method: 'POST' })
}
export interface AdminFileListQuery {
page: number
size: number
keyword?: string
}
/** 文件列表(宽松解析:兼容 data 字段或 list 字段、total 顶层) */
export async function adminFileList(q: AdminFileListQuery): Promise<AdminFileListResult> {
const raw = await request<Record<string, unknown>>(paths.adminFileList, {
query: { page: q.page, size: q.size, keyword: q.keyword || undefined },
})
const rows = (pick<unknown[]>(raw, ['data', 'list', 'items', 'files']) ?? []) as Record<string, unknown>[]
const total = Number(pick<number>(raw, ['total', 'count']) ?? rows.length)
return {
page: Number(pick<number>(raw, ['page']) ?? q.page),
size: Number(pick<number>(raw, ['size']) ?? q.size),
total,
data: rows.map((r) => ({
id: Number(pick<number>(r, ['id']) ?? 0),
code: String(pick<string>(r, ['code']) ?? ''),
name: String(pick<string>(r, ['name']) ?? `${pick<string>(r, ['prefix']) ?? ''}${pick<string>(r, ['suffix']) ?? ''}`),
suffix: String(pick<string>(r, ['suffix']) ?? ''),
size: Number(pick<number>(r, ['size']) ?? 0),
isText: Boolean(pick<boolean>(r, ['isText', 'is_text']) ?? false),
expiredAt: (pick<string>(r, ['expiredAt', 'expired_at', 'expires_at']) as string) ?? null,
expiredCount: (pick<number>(r, ['expiredCount', 'expired_count']) as number) ?? null,
usedCount: Number(pick<number>(r, ['usedCount', 'used_count']) ?? 0),
createdAt: (pick<string>(r, ['createdAt', 'created_at']) as string) ?? null,
isExpired: Boolean(pick<boolean>(r, ['isExpired', 'is_expired']) ?? false),
})),
}
}
/** 删除单个文件 */
export async function adminFileDelete(id: number): Promise<void> {
await request<void>(paths.adminFileDelete, { method: 'DELETE', json: { id } })
}
/** 批量删除(契约:POST /admin/file/batch-deletebody {ids:number[]} */
export async function adminFileBatchDelete(ids: number[]): Promise<void> {
await request<void>(paths.adminFileBatchDelete, { method: 'POST', json: { ids } })
}
/** 更新分享(code/过期时间/次数;go-api 定稿 PATCH /admin/file/update */
export async function adminFileUpdate(payload: AdminFileUpdatePayload): Promise<void> {
await request<void>(paths.adminFileUpdate, { method: 'PATCH', json: payload })
}
/** 读取管理端配置(KV 全量,含 site_name/logo_url/favicon_url */
export async function adminConfigGet(): Promise<AdminConfig> {
const raw = await request<unknown>(paths.adminConfigGet)
// 兼容:后端可能直接返回 KV 对象,或包一层 config/data
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
const obj = raw as Record<string, unknown>
const inner = (pick<Record<string, unknown>>(obj, ['config', 'data', 'settings']) ?? obj) as Record<string, unknown>
return inner
}
return {}
}
/** 更新管理端配置(PATCH 部分字段 JSON;提交后全站生效,含 site_name/logo_url/favicon_url */
export async function adminConfigUpdate(patch: Record<string, unknown>): Promise<void> {
await request<void>(paths.adminConfigUpdate, { method: 'PATCH', json: patch })
}
/** v3 存储引擎热切换:成功返回当前引擎名;失败(503)服务端保持原引擎并抛 ApiError */
export async function adminStorageSwitch(engine: 'local' | 's3' | 'webdav'): Promise<string> {
const data = await request<{ ok: boolean; engine: string }>(paths.adminStorageSwitch, {
method: 'POST',
json: { engine },
})
return String(data?.engine ?? engine)
}
/** 修改管理员密码(成功后旧 token 全部失效,需重新登录) */
export async function adminPasswordUpdate(oldPassword: string, newPassword: string): Promise<void> {
await request<void>(paths.adminPasswordUpdate, {
method: 'PATCH',
json: { old_password: oldPassword, new_password: newPassword },
})
}
export interface AuditListQuery {
page: number
size: number
action?: string
result?: string
ip?: string
/** ISO 或 YYYY-MM-DD;由调用方格式化 */
startTime?: string
endTime?: string
}
/** 审计日志列表(宽松解析兼容 data/list 字段与 snake_case/camelCase 行字段) */
export async function adminAuditList(q: AuditListQuery): Promise<AuditListResult> {
const raw = await request<Record<string, unknown>>(paths.adminAuditList, {
query: {
page: q.page,
size: q.size,
action: q.action || undefined,
result: q.result || undefined,
ip: q.ip || undefined,
start_time: q.startTime || undefined,
end_time: q.endTime || undefined,
},
})
const rows = (pick<unknown[]>(raw, ['data', 'list', 'items', 'logs']) ?? []) as Record<string, unknown>[]
const total = Number(pick<number>(raw, ['total', 'count']) ?? rows.length)
return {
page: Number(pick<number>(raw, ['page']) ?? q.page),
size: Number(pick<number>(raw, ['size']) ?? q.size),
total,
data: rows.map((r, idx) => ({
id: Number(pick<number>(r, ['id']) ?? idx + 1),
action: String(pick<string>(r, ['action']) ?? ''),
result: String(pick<string>(r, ['result']) ?? ''),
fileCode: String(pick<string>(r, ['file_code', 'fileCode', 'code']) ?? ''),
fileName: String(pick<string>(r, ['file_name', 'fileName', 'name']) ?? ''),
sizeBytes: (pick<number>(r, ['size_bytes', 'sizeBytes', 'size']) as number) ?? null,
transferredBytes: (pick<number>(r, ['transferred_bytes', 'transferredBytes', 'bytes']) as number) ?? null,
ip: String(pick<string>(r, ['ip', 'client_ip', 'clientIp']) ?? ''),
userAgent: String(pick<string>(r, ['user_agent', 'userAgent']) ?? ''),
deviceOs: String(pick<string>(r, ['device_os', 'deviceOs', 'os']) ?? ''),
deviceBrowser: String(pick<string>(r, ['device_browser', 'deviceBrowser', 'browser']) ?? ''),
deviceType: String(pick<string>(r, ['device_type', 'deviceType']) ?? ''),
actor: String(pick<string>(r, ['actor']) ?? ''),
errorMsg: String(pick<string>(r, ['error_msg', 'errorMsg', 'error']) ?? ''),
durationMs: (pick<number>(r, ['duration_ms', 'durationMs', 'duration']) as number) ?? null,
createdAt: (pick<string>(r, ['created_at', 'createdAt', 'time']) as string) ?? null,
})),
}
}
+58
View File
@@ -0,0 +1,58 @@
/** 分片上传 API(对齐 t3 契约:init / upload / status / finish / cancel */
import { request } from './http'
import { paths } from './paths'
import type { ChunkFinishResult, ChunkInitResult, ChunkStatusResult } from './types'
/** 初始化分片上传会话(file_hash 用于断点续传匹配) */
export function initChunkUpload(
fileName: string,
fileSize: number,
chunkSize: number,
fileHash: string,
): Promise<ChunkInitResult> {
return request<ChunkInitResult>(paths.chunkInit, {
method: 'POST',
json: { file_name: fileName, file_size: fileSize, chunk_size: chunkSize, file_hash: fileHash },
timeout: 60_000,
})
}
/**
* 上传单个分片:路径式 /chunk/upload/{uploadID}/{chunkIndex}multipart 字段 chunk。
* 兼容处理:同时附 upload_id / chunk_index 表单字段(后端按表单读取时可用,按路径
* 参数读取时忽略无副作用)。
*/
export async function uploadOneChunk(uploadId: string, index: number, blob: Blob): Promise<void> {
const fd = new FormData()
fd.append('upload_id', uploadId)
fd.append('chunk_index', String(index))
fd.append('chunk', blob, `chunk-${index}`)
await request<{ chunk_hash?: string }>(paths.chunkUpload(uploadId, index), {
method: 'POST',
formData: fd,
timeout: 120_000,
})
}
export function chunkStatus(uploadId: string): Promise<ChunkStatusResult> {
return request<ChunkStatusResult>(paths.chunkStatus(uploadId), { timeout: 60_000 })
}
/** 合并分片并创建分享 */
export function finishChunkUpload(
uploadId: string,
expireValue: number,
expireStyle: string,
code = '',
): Promise<ChunkFinishResult> {
return request<ChunkFinishResult>(paths.chunkFinish(uploadId), {
method: 'POST',
json: { expire_value: expireValue, expire_style: expireStyle, code },
timeout: 300_000,
})
}
/** 取消上传并清理分片 */
export async function cancelChunkUpload(uploadId: string): Promise<void> {
await request<void>(paths.chunkCancel(uploadId), { method: 'DELETE', timeout: 60_000 })
}
+209
View File
@@ -0,0 +1,209 @@
/**
* HTTP 客户端:统一处理 {code,msg,data} 响应封装、Bearer 认证、超时与错误归一。
* 业务层拿到的永远是 data,非 200 一律抛 ApiError。
* paths.ts 中的路径均为完整路径(业务接口根路径 /share /chunk /admin,公共 /api/v1)。
*/
import { api } from './paths'
export class ApiError extends Error {
/** 业务错误码(响应体 code)或 HTTP 状态码 */
code: number
msg: string
httpStatus?: number
constructor(code: number, msg: string, httpStatus?: number) {
super(msg || `请求失败(${code}`)
this.name = 'ApiError'
this.code = code
this.msg = msg || `请求失败(${code}`
this.httpStatus = httpStatus
}
}
const TOKEN_KEY = 'fcb_admin_token'
export function getToken(): string {
try {
return localStorage.getItem(TOKEN_KEY) ?? ''
} catch {
return ''
}
}
export function setToken(token: string): void {
try {
if (token) localStorage.setItem(TOKEN_KEY, token)
else localStorage.removeItem(TOKEN_KEY)
} catch {
/* localStorage 不可用时忽略 */
}
}
/** 401 时的全局处理(由 router 注入:跳转管理登录页) */
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(fn: () => void): void {
unauthorizedHandler = fn
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
/** JSON 请求体 */
json?: unknown
/** application/x-www-form-urlencoded 请求体 */
form?: Record<string, string>
/** multipart/form-data 请求体(Content-Type 由浏览器自动设置) */
formData?: FormData
/** URL 查询参数 */
query?: Record<string, string | number | undefined>
/** 超时毫秒,默认 30s;上传等长任务可调大 */
timeout?: number
signal?: AbortSignal
}
interface Envelope<T> {
code: number
msg?: string
data: T
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
const url = new URL(api(path), location.origin)
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && `${v}` !== '') url.searchParams.set(k, `${v}`)
}
}
return url.toString()
}
function authHeaders(): Record<string, string> {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
/** 发起请求并解包 {code,msg,data}。code!==200 或 HTTP 非 2xx → ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', json, form, formData, query, timeout = 30_000, signal } = opts
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new DOMException('请求超时', 'TimeoutError')), timeout)
if (signal) signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true })
const headers: Record<string, string> = { ...authHeaders() }
if (json !== undefined) headers['Content-Type'] = 'application/json'
let body: BodyInit | undefined
if (json !== undefined) body = JSON.stringify(json)
else if (formData) body = formData
else if (form) {
// v3.1.1 根因修复:URLSearchParams.toString() 产出的是字符串 body
// fetch 对字符串默认 Content-Type: text/plain —— 后端无法按表单解析,
// 文本分享被静默存成空(或被空文本校验拒绝)。必须显式声明 urlencoded。
body = new URLSearchParams(form).toString()
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'
}
let res: Response
try {
res = await fetch(buildUrl(path, query), { method, headers, body, signal: controller.signal })
} catch (e) {
if (e instanceof DOMException && e.name === 'TimeoutError') throw new ApiError(0, '请求超时,请检查网络或稍后重试')
throw new ApiError(0, '网络异常,无法连接服务器')
} finally {
clearTimeout(timer)
}
// 非 JSON 响应(如裸文本错误页)
const contentType = res.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
const text = await res.text().catch(() => '')
if (!res.ok) throw new ApiError(res.status, text.slice(0, 200) || `请求失败(HTTP ${res.status}`, res.status)
throw new ApiError(res.status, '响应格式异常(非 JSON', res.status)
}
let env: Envelope<T>
try {
env = (await res.json()) as Envelope<T>
} catch {
throw new ApiError(res.status, '响应 JSON 解析失败', res.status)
}
if (!res.ok || env.code !== 200) {
const code = env.code ?? res.status
if (code === 401 || res.status === 401) {
setToken('')
unauthorizedHandler?.()
}
throw new ApiError(code, env.msg || `请求失败(${code}`, res.status)
}
return env.data
}
/**
* 取件接口专用:/share/select 返回的是纯文本或文件流(而非 JSON 封装)。
* 错误时后端仍可能返回 JSON 封装 → 统一转成 ApiError。
*/
export async function requestRaw(
path: string,
opts: { query?: Record<string, string>; timeout?: number } = {},
): Promise<{ blob: Blob; contentType: string }> {
const { query, timeout = 120_000 } = opts
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new DOMException('请求超时', 'TimeoutError')), timeout)
let res: Response
try {
res = await fetch(buildUrl(path, query), { method: 'GET', headers: authHeaders(), signal: controller.signal })
} catch {
throw new ApiError(0, '网络异常,无法连接服务器')
} finally {
clearTimeout(timer)
}
const contentType = res.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
try {
const env = (await res.json()) as Envelope<unknown>
throw new ApiError(env.code ?? res.status, env.msg || '取件失败', res.status)
} catch (e) {
if (e instanceof ApiError) throw e
throw new ApiError(res.status, '取件失败', res.status)
}
}
if (!res.ok) throw new ApiError(res.status, `取件失败(HTTP ${res.status}`, res.status)
return { blob: await res.blob(), contentType }
}
/** 带上传进度的 POSTXMLHttpRequestfetch 无法拿到上传进度) */
export function uploadWithProgress<T>(
path: string,
form: FormData,
onProgress?: (percent: number) => void,
timeout = 600_000,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', buildUrl(path))
xhr.timeout = timeout
const token = getToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100))
}
xhr.onload = () => {
try {
const env = JSON.parse(xhr.responseText) as Envelope<T>
if (xhr.status >= 200 && xhr.status < 300 && env.code === 200) resolve(env.data)
else {
if (env.code === 401 || xhr.status === 401) {
setToken('')
unauthorizedHandler?.()
}
reject(new ApiError(env.code ?? xhr.status, env.msg || `上传失败(HTTP ${xhr.status}`, xhr.status))
}
} catch {
reject(new ApiError(xhr.status, `上传失败(HTTP ${xhr.status}`, xhr.status))
}
}
xhr.onerror = () => reject(new ApiError(0, '网络异常,上传失败'))
xhr.ontimeout = () => reject(new ApiError(0, '上传超时,请重试'))
xhr.send(form)
})
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 统一 API 路径契约(最终版,经队长终审裁定):
*
* ⚠️ 业务 API 挂**根路径**(与参考实现及 t1 审计中间件路由分类器一致):
* /share/*、/chunk/*、/presign/*、/admin/*、/setup —— 一律不带 /api/v1 前缀!
* 仅以下两个公共端点带 /api/v1 前缀(t1 冻结):/api/v1/health、/api/v1/config。
* 若业务路径误加前缀,线上将全部 404 且审计分类失效。
*
* - baseURL 通过 VITE_API_BASE 配置;为空表示同源部署(后端 embed dist/ + NoRoute SPA 回退)
* - 若后端调整路径,只需改本文件
*/
/** API 根地址(去掉末尾斜杠)。为空 = 同源。 */
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? '').trim().replace(/\/+$/, '')
/** 拼接完整接口地址(path 为含前缀的完整路径:业务路径以 / 开头直接拼根) */
export function api(path: string): string {
return `${API_BASE}${path}`
}
/** 取件页站内路由(router hash/history 模式自适应) */
function routerBase(): string {
return (import.meta.env.VITE_ROUTER_MODE ?? 'history') === 'history' ? '/s/' : '/#/s/'
}
export function pickupPageUrl(code: string, base?: string): string {
// v3.1:配置了站点对外域名时用之(内网访问也能生成公网链接),否则当前地址
const origin = (base?.trim() || location.origin).replace(/\/$/, '')
return `${origin}${routerBase()}${encodeURIComponent(code)}`
}
/**
* 全部业务端点(最终版)。
* 与 docs/openapi.yaml 及 docs/api/*.mdtech-writer 权威文档)逐项核对一致;变更须同步文档。
*/
export const paths = {
// 公共(/api/v1 前缀,t1 冻结的两个例外)
health: '/api/v1/health',
publicConfig: '/api/v1/config',
// 初始化(HTML 向导/表单,整页跳转用;未初始化时其余接口一律 428)
setup: '/setup', // GET 向导 / POST 表单提交
// 分享(根路径;select 会消耗取件次数:仅真正取件时调用,勿做预览轮询)
shareText: '/share/text', // POST form: text, expire_value, expire_style
shareFile: '/share/file', // POST multipart: file, expire_value, expire_style
shareMetadata: '/share/metadata', // GET ?code=(不消耗次数)
shareSelect: '/share/select', // GET ?code=(文本 text/plain / 文件流 200|206 Range
shareDownload: '/share/download', // GET ?key=<token>&code=<code>(备忘)
// 分片上传(根路径)
chunkInit: '/chunk/upload/init', // POST JSON {file_name, chunk_size, file_size, file_hash}
chunkUpload: (uploadId: string, index: number) =>
`/chunk/upload/${encodeURIComponent(uploadId)}/${index}`, // POST multipart: chunk
chunkStatus: (uploadId: string) => `/chunk/upload/status/${encodeURIComponent(uploadId)}`, // GET
chunkFinish: (uploadId: string) => `/chunk/upload/complete/${encodeURIComponent(uploadId)}`, // POST JSON {expire_value, expire_style}
chunkCancel: (uploadId: string) => `/chunk/upload/${encodeURIComponent(uploadId)}`, // DELETE
// 预签名直传(备忘,当前前端版本未使用)
presignInit: '/presign/upload/init',
presignProxyUpload: (uploadId: string) => `/presign/upload/proxy/${encodeURIComponent(uploadId)}`,
presignConfirm: (uploadId: string) => `/presign/upload/confirm/${encodeURIComponent(uploadId)}`,
presignStatus: (uploadId: string) => `/presign/upload/status/${encodeURIComponent(uploadId)}`,
presignCancel: (uploadId: string) => `/presign/upload/${encodeURIComponent(uploadId)}`,
// 管理后台(根路径)
adminLogin: '/admin/login', // POST JSON {password};失败 401,连续失败 423
adminVerify: '/admin/verify', // GET
adminLogout: '/admin/logout', // POST
adminDashboard: '/admin/dashboard', // GET(备忘)
adminFileList: '/admin/file/list', // GET ?page&size&keyword
adminFileDelete: '/admin/file/delete', // DELETE {id}
adminFileBatchDelete: '/admin/file/batch-delete', // POST {ids:number[]}
adminFileUpdate: '/admin/file/update', // PATCH {id, code?, expired_at?, expired_count?}
adminConfigGet: '/admin/config/get', // GETadmin_token 屏蔽、jwt_secret 不下发)
adminConfigUpdate: '/admin/config/update', // PATCH 部分字段 JSONPOST 亦可)
adminAuditList: '/admin/audit/list', // GET ?page&size&action&result&ip&start_time&end_time(ISO 8601)
adminPasswordUpdate: '/admin/settings/password', // PATCH {old_password,new_password}(轮换 jwt_secret,旧 token 全失效)
adminStorageSwitch: '/admin/storage/switch', // v3 POST {engine}(热切换,健康检查通过才生效)
} as const
/** 站内文档与 OpenAPI 资源地址(构建时嵌入优先,运行时回退) */
export function remoteDocUrl(slug: string): string {
return `${API_BASE}/docs/api/${encodeURIComponent(slug)}.md`
}
export function remoteOpenApiUrl(): string {
return `${API_BASE}/docs/openapi.yaml`
}
+106
View File
@@ -0,0 +1,106 @@
/** 分享相关 API(对齐 t3 契约:POST /share/text/、POST /share/file/、GET /share/metadata、GET /share/select */
import { request, requestRaw, uploadWithProgress, ApiError, getToken } from './http'
import { api, paths } from './paths'
import type { ShareFileResult, ShareMetadata, ShareTextResult } from './types'
import { filenameFromDisposition, pick } from '@/utils/format'
/** 分享文本(对齐参考:表单字段 text / expire_value / expire_style */
export function shareText(text: string, expireValue: number, expireStyle: string, code = ''): Promise<ShareTextResult> {
return request<ShareTextResult>(paths.shareText, {
method: 'POST',
form: { text, expire_value: String(expireValue), expire_style: expireStyle, code },
})
}
/** 直传分享文件(multipart),带进度回调 */
export function shareFile(
file: File,
expireValue: number,
expireStyle: string,
onProgress?: (percent: number) => void,
code = '',
): Promise<ShareFileResult> {
const fd = new FormData()
fd.append('file', file)
fd.append('expire_value', String(expireValue))
fd.append('expire_style', expireStyle)
if (code) fd.append('code', code)
return uploadWithProgress<ShareFileResult>(paths.shareFile, fd, onProgress)
}
/** 查询分享元信息(不消耗取件次数) */
export async function getMetadata(code: string): Promise<ShareMetadata> {
const raw = await request<Record<string, unknown>>(paths.shareMetadata, { query: { code } })
const isText = Boolean(
pick<boolean>(raw, ['is_text', 'isText']) ?? pick<string>(raw, ['type']) === 'text',
)
return {
code: String(pick<string>(raw, ['code']) ?? code),
name: String(pick<string>(raw, ['name']) ?? '未命名'),
size: Number(pick<number>(raw, ['size']) ?? 0),
type: isText ? 'text' : 'file',
isText,
createdAt: (pick<string>(raw, ['created_at', 'createdAt']) as string) ?? null,
expiredAt: (pick<string>(raw, ['expired_at', 'expires_at', 'expiredAt', 'expiresAt']) as string) ?? null,
expiredCount:
(pick<number>(raw, ['expired_count', 'expiredCount', 'remaining_downloads', 'remainingDownloads']) as number) ??
null,
usedCount: Number(pick<number>(raw, ['used_count', 'usedCount']) ?? 0),
remainingDownloads:
(pick<number>(raw, ['remaining_downloads', 'remainingDownloads', 'expired_count', 'expiredCount']) as number) ??
null,
}
}
/** 取件:文本 → 返回文本内容(错误统一抛 ApiError) */
export async function pickupText(code: string): Promise<string> {
const { blob } = await requestRaw(paths.shareSelect, { query: { code } })
return blob.text()
}
/** 用 XHR 拉取文件 Blob(带下载进度);文件名优先取 Content-Disposition */
export function pickupFileWithProgress(
code: string,
onProgress?: (percent: number) => void,
): Promise<{ blob: Blob; filename: string | null }> {
return new Promise((resolve, reject) => {
const url = new URL(api(paths.shareSelect), location.origin)
url.searchParams.set('code', code)
const xhr = new XMLHttpRequest()
xhr.open('GET', url.toString())
xhr.timeout = 600_000
xhr.responseType = 'blob'
const token = getToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100))
}
xhr.onload = () => {
const ct = xhr.getResponseHeader('content-type') ?? ''
if (ct.includes('application/json')) {
const reader = new FileReader()
reader.onload = () => {
try {
const env = JSON.parse(String(reader.result)) as { code?: number; msg?: string }
reject(new ApiError(env.code ?? xhr.status, env.msg || '取件失败', xhr.status))
} catch {
reject(new ApiError(xhr.status, '取件失败', xhr.status))
}
}
reader.readAsText(xhr.response as Blob)
return
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve({
blob: xhr.response as Blob,
filename: filenameFromDisposition(xhr.getResponseHeader('content-disposition')),
})
} else {
reject(new ApiError(xhr.status, `取件失败(HTTP ${xhr.status}`, xhr.status))
}
}
xhr.onerror = () => reject(new ApiError(0, '网络异常,取件失败'))
xhr.ontimeout = () => reject(new ApiError(0, '下载超时,请重试'))
xhr.send()
})
}
+135
View File
@@ -0,0 +1,135 @@
/** 前后端数据结构(宽松定义:兼容 camelCase / snake_case 由各 normalizer 归一) */
export interface PublicConfig {
name: string
description: string
explain: string
uploadSize: number
allowedFileTypes: string[]
expireStyle: string[]
enableChunk: boolean
openUpload: boolean
notifyTitle: string
notifyContent: string
logoUrl: string
faviconUrl: string
}
export interface ShareMetadata {
code: string
name: string
size: number
type: 'text' | 'file'
isText: boolean
createdAt: string | null
expiredAt: string | null
/** -1 / null = 不限次数;>0 = 剩余次数 */
expiredCount: number | null
usedCount: number
remainingDownloads: number | null
}
export interface ShareTextResult {
code: string
}
export interface ShareFileResult {
code: string
name: string
}
export interface ChunkInitResult {
existed?: boolean
upload_id: string
chunk_size: number
total_chunks: number
uploaded_chunks: number[]
}
export interface ChunkStatusResult {
upload_id: string
file_name?: string
file_size?: number
chunk_size: number
total_chunks: number
uploaded_chunks: number[]
progress?: number
}
export interface ChunkFinishResult {
code: string
name: string
}
export interface AdminFileInfo {
id: number
code: string
name: string
suffix: string
size: number
isText: boolean
expiredAt: string | null
/** -1 / null = 永久 */
expiredCount: number | null
usedCount: number
createdAt: string | null
isExpired: boolean
}
export interface AdminFileListResult {
page: number
size: number
total: number
data: AdminFileInfo[]
}
export interface AdminFileUpdatePayload {
id: number
code?: string
prefix?: string
suffix?: string
expired_at?: string
expired_count?: number
}
export interface AdminConfig {
/** 直接透传后端 KV(键名以 /admin/config/get 实际返回为准,宽松处理) */
[key: string]: unknown
}
export type AuditAction = 'upload' | 'download' | string
export type AuditResult = 'success' | 'failed' | 'denied' | string
export interface AuditLogEntry {
id: number
action: AuditAction
result: AuditResult
fileCode: string
fileName: string
sizeBytes: number | null
transferredBytes: number | null
ip: string
userAgent: string
deviceOs: string
deviceBrowser: string
deviceType: string
actor: string
errorMsg: string
durationMs: number | null
createdAt: string | null
}
export interface AuditListResult {
page: number
size: number
total: number
data: AuditLogEntry[]
}
export interface AdminLoginResult {
token: string
token_type?: string
expires_in?: number
expires_at?: number
username?: string
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
/** 简易模态框(Teleport 到 bodyEsc 关闭) */
import { onBeforeUnmount, onMounted } from 'vue'
const props = defineProps<{
open: boolean
title: string
width?: string
}>()
const emit = defineEmits<{ close: [] }>()
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && props.open) emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div v-if="open" class="modal-overlay" @click.self="emit('close')">
<div class="modal" :style="width ? { maxWidth: width } : undefined" role="dialog" aria-modal="true" :aria-label="title">
<h3 class="modal-title">{{ title }}</h3>
<slot />
</div>
</div>
</Teleport>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
/**
* 有效期选择器(需求⑧:按 config 的 expireStyle / max_save_seconds / max_save_count 动态渲染):
* - 选项来自 config.expireStyle 白名单
* - style=count 时数值上限 = max_save_count0=不限),并展示提示
* - style=day/hour/minute 时按 max_save_seconds 折算上限并展示提示
* - 策略最终由服务端强制(超限 403/400 中文错误),这里仅体验层提示
*/
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfigStore } from '@/stores/config'
import { EXPIRE_STYLES } from '@/utils/format'
const props = defineProps<{
value: number
style: string
compact?: boolean
}>()
const emit = defineEmits<{
'update:value': [v: number]
'update:style': [v: string]
}>()
const { t, te } = useI18n()
const config = useConfigStore()
const styles = computed(() => {
const list = config.expireStyle.length ? config.expireStyle : ['day', 'hour', 'minute', 'forever', 'count']
const known = EXPIRE_STYLES.filter((s) => list.includes(s.value))
const extra = list.filter((s) => !EXPIRE_STYLES.some((k) => k.value === s)).map((s) => ({ value: s, label: s }))
return [...known, ...extra]
})
const isForever = computed(() => props.style === 'forever')
const isCount = computed(() => props.style === 'count')
/** 需求⑧:count 上限 = max_save_count0=不限) */
const countMax = computed(() => (config.maxSaveCount > 0 ? config.maxSaveCount : 9999))
/** 需求⑧:时间类上限按 max_save_seconds 折算(0=仅默认 7 天兜底,不额外提示) */
const secondsMax = computed(() => (config.maxSaveSeconds > 0 ? config.maxSaveSeconds : 0))
const unitSeconds: Record<string, number> = { day: 86400, hour: 3600, minute: 60 }
/** 时间型选项的数值上限(按 max_save_seconds 折算;无限制时 9999 */
function maxFor(style: string): number {
const sec = secondsMax.value
const unit = unitSeconds[style]
if (!unit) return style === 'count' ? countMax.value : 9999
if (sec <= 0) return 9999
return Math.max(1, Math.floor(sec / unit))
}
/** 需求⑧:超出上限时给出体验层提示(不拦截提交,服务端强制) */
const overPolicy = computed(() => {
if (isCount.value && config.maxSaveCount > 0 && props.value > config.maxSaveCount) {
return t('expire.maxCountHint', { n: config.maxSaveCount })
}
if (!isForever.value && !isCount.value && secondsMax.value > 0) {
const max = maxFor(props.style)
if (props.value > max) {
const unitLabel = EXPIRE_STYLES.find((s) => s.value === props.style)?.label ?? props.style
return t('expire.maxSecondsHint', { value: `${max} ${unitLabel}` })
}
}
return ''
})
const unitLabel = computed(() => EXPIRE_STYLES.find((s) => s.value === props.style)?.label ?? props.style)
/** 需求⑩:已知样式走 i18n,未知样式原样展示 */
function styleLabel(value: string, raw: string): string {
const key = `expireStyle.${value}`
return te(key) ? t(key) : raw
}
function onStyleChange(e: Event): void {
const v = (e.target as HTMLSelectElement).value
emit('update:style', v)
if (v === 'count' && props.value > countMax.value) emit('update:value', 1)
if (v === 'minute' && props.value < 1) emit('update:value', 10)
}
</script>
<template>
<div class="field-row">
<label v-if="!isForever" class="expire-value" :class="{ compact }" :style="compact ? 'flex:0 0 110px' : ''">
<span class="field-sub">{{ t('expire.value') }}</span>
<input
class="input"
type="number"
:min="1"
:max="maxFor(style)"
:value="value"
@input="emit('update:value', Math.max(1, Number(($event.target as HTMLInputElement).value) || 1))"
/>
</label>
<label :style="isForever ? 'flex:1' : ''">
<span class="field-sub">{{ t('expire.label') }}</span>
<select class="select" :value="style" @change="onStyleChange">
<option v-for="s in styles" :key="s.value" :value="s.value">
{{ isForever && s.value === 'forever' ? t('expire.foreverOption') : s.value === 'count' ? t('expire.countOption') : styleLabel(s.value, s.label) }}
</option>
</select>
</label>
</div>
<p v-if="overPolicy" class="hint" style="color: var(--c-warn)">{{ overPolicy }}</p>
<p v-else-if="style === 'count'" class="hint">{{ t('expire.countHint') }}</p>
<p v-else-if="!isForever" class="hint">{{ t('expire.timeHint', { value, unit: unitLabel }) }}</p>
<p v-else class="hint">{{ t('expire.foreverHint') }}</p>
</template>
<style scoped>
.field-sub {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--c-text-2);
margin-bottom: 6px;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
/** 文件拖拽/点选组件(需求⑧:allowedFileTypes 过滤文件选择器 accept;大小提示对齐 max_file_size */
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatBytes } from '@/utils/format'
const props = defineProps<{
modelValue: File | null
/** 最大字节数,超限提示 */
maxSize?: number
disabled?: boolean
/** 需求⑧:类型白名单(星号表示不限制;含斜杠按 MIME 匹配,否则按扩展名) */
acceptTypes?: string[]
}>()
const emit = defineEmits<{
'update:modelValue': [f: File | null]
}>()
const { t } = useI18n()
const inputRef = ref<HTMLInputElement | null>(null)
const dragover = ref(false)
const error = ref('')
/** 需求⑧:把白名单映射为 <input accept> 值("*" → 空 = 不限制) */
const acceptAttr = computed(() => {
const rules = (props.acceptTypes ?? []).map((r) => r.trim()).filter(Boolean)
if (!rules.length || rules.some((r) => r === '*' || r === '*/*')) return ''
return rules
.map((r) => (r.includes('/') ? r : r.startsWith('.') ? r : `.${r.toLowerCase()}`))
.join(',')
})
const acceptHint = computed(() => {
const rules = (props.acceptTypes ?? []).filter((r) => r && r !== '*' && r !== '*/*')
return rules.length ? t('drop.typeHint', { types: rules.join(', ') }) : ''
})
function accept(f: File | null | undefined): void {
error.value = ''
if (!f) return
if (props.maxSize && f.size > props.maxSize) {
error.value = t('drop.tooLarge', { size: formatBytes(f.size), limit: formatBytes(props.maxSize) })
emit('update:modelValue', null)
return
}
emit('update:modelValue', f)
}
function onDrop(e: DragEvent): void {
dragover.value = false
if (props.disabled) return
accept(e.dataTransfer?.files?.[0])
}
function onPick(e: Event): void {
const input = e.target as HTMLInputElement
accept(input.files?.[0])
input.value = ''
}
</script>
<template>
<div>
<div
v-if="!modelValue"
class="dropzone"
:class="{ dragover, disabled }"
role="button"
tabindex="0"
:aria-label="t('drop.aria')"
@click="!disabled && inputRef?.click()"
@keydown.enter.prevent="!disabled && inputRef?.click()"
@dragover.prevent="dragover = true"
@dragleave="dragover = false"
@drop.prevent="onDrop"
>
<div class="dz-icon" aria-hidden="true">📦</div>
<div class="dz-main">{{ t('drop.zone') }}</div>
<div class="dz-sub">
<template v-if="maxSize">{{ t('drop.maxSize', { size: formatBytes(maxSize) }) }}</template>
<template v-else>{{ t('drop.noLimit') }}</template>
<template v-if="acceptHint"> · {{ acceptHint }}</template>
</div>
</div>
<div v-else class="file-chip">
<span aria-hidden="true">📄</span>
<span class="fc-name">{{ modelValue.name }}</span>
<span class="fc-size">{{ formatBytes(modelValue.size) }}</span>
<button class="fc-remove" type="button" :title="t('drop.remove')" @click="emit('update:modelValue', null)"></button>
</div>
<p v-if="error" class="hint" style="color: var(--c-danger)">{{ error }}</p>
<input ref="inputRef" type="file" hidden :accept="acceptAttr" @change="onPick" />
</div>
</template>
<style scoped>
.dropzone.disabled { opacity: 0.55; cursor: not-allowed; }
</style>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
/** 需求⑦:系统通知右上角玻璃悬浮卡(可手动关闭;由 App.vue 管理记忆逻辑) */
defineProps<{
title: string
/** 受控 HTML(与后端 notify_content 语义一致:允许 <a> 等);纯文本自动转义不可用,此处直接受信渲染 */
content: string
}>()
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<aside class="notify-pop" role="dialog" aria-live="polite" :aria-label="title || $t('notify.title')">
<div class="notify-head">
<span aria-hidden="true">🔔</span>
<span class="notify-title-text">{{ title || $t('notify.title') }}</span>
<button class="notify-close" type="button" :title="$t('notify.close')" :aria-label="$t('notify.close')" @click="emit('close')">
</button>
</div>
<!-- 内容为管理端配置的受控 HTML后端契约允许 <a>纯文本场景浏览器按普通文本渲染 -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="notify-content" v-html="content"></div>
</aside>
</template>
<style scoped>
.notify-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
/**
* 页面外壳:导航 + 内容 + 页脚。
* 需求⑥:页脚承载自定义文案 footer_text、备案号 footer_beian 与 API 文档/OpenAPI/管理后台小字链接,
* 消除与顶部导航的重复。
*/
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfigStore } from '@/stores/config'
import SiteNav from './SiteNav.vue'
const { t } = useI18n()
const config = useConfigStore()
const year = new Date().getFullYear()
const hasCustomFooter = computed(() => Boolean(config.footerText.trim() || config.footerBeian.trim()))
</script>
<template>
<SiteNav />
<main class="page" :class="{ 'page-wide': $route.meta.wide }">
<slot />
</main>
<footer class="site-footer">
<div class="footer-left">
<span v-if="config.footerText.trim()" class="footer-text">{{ config.footerText }}</span>
<span v-if="config.footerBeian.trim()" class="footer-beian">{{ config.footerBeian }}</span>
<!-- 需求无自定义文案时显示默认版权行 -->
<span v-if="!hasCustomFooter">{{ t('footer.copyright', { year, name: config.displayName }) }}</span>
</div>
<!-- 页脚中间Powered by SKYMirror -->
<span class="footer-powered">
Powered by <a href="https://skymirror.top" target="_blank" rel="noopener noreferrer">SKYMirror</a> 26.9
</span>
<nav :aria-label="t('footer.linkNav')">
<RouterLink to="/docs">{{ t('footer.docs') }}</RouterLink>
<RouterLink to="/openapi">{{ t('footer.openapi') }}</RouterLink>
<RouterLink to="/admin/files">{{ t('footer.admin') }}</RouterLink>
</nav>
</footer>
</template>
<style scoped>
.footer-left {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.footer-powered {
font-size: 13px;
color: var(--c-text-2);
white-space: nowrap;
}
.footer-powered a {
color: var(--c-text-2);
text-decoration: none;
border-bottom: 1px dotted currentColor;
}
.footer-powered a:hover {
color: var(--c-text);
}
@media (min-width: 720px) {
.site-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.footer-left {
flex-direction: row;
align-items: center;
gap: 16px;
}
.footer-left .footer-beian::before {
content: '·';
margin-right: 16px;
opacity: 0.7;
}
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
/** 通用分页器 */
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
page: number
size: number
total: number
}>()
const emit = defineEmits<{
change: [page: number, size: number]
}>()
const { t } = useI18n()
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.size)))
function go(p: number): void {
const target = Math.min(Math.max(1, p), totalPages.value)
if (target !== props.page) emit('change', target, props.size)
}
</script>
<template>
<div class="pager">
<span class="pager-info">{{ t('common.pagerInfo', { total, page, pages: totalPages }) }}</span>
<button class="btn btn-ghost btn-sm" type="button" :disabled="page <= 1" @click="go(page - 1)">
{{ t('common.previousPage') }}
</button>
<button class="btn btn-ghost btn-sm" type="button" :disabled="page >= totalPages" @click="go(page + 1)">
{{ t('common.nextPage') }}
</button>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
/**
* 分享成功结果卡:取件码 + 站内取件链接。
* 链接已含取件码(/s/<code>):复制链接即完整信息,不再额外拼接提取码
* (旧版拼接导致粘贴访问时整串被当作取件码而 404)。
*/
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { pickupPageUrl } from '@/api/paths'
import { copyText, expireStyleLabel } from '@/utils/format'
import { useToastStore } from '@/stores/toast'
import { useConfigStore } from '@/stores/config'
const props = defineProps<{
code: string
name?: string
expireValue?: number
expireStyle?: string
}>()
const { t } = useI18n()
const toast = useToastStore()
const config = useConfigStore()
const copied = ref<'code' | 'link' | null>(null)
const link = computed(() => pickupPageUrl(props.code, config.shareLinkBase))
async function copy(value: 'code' | 'link'): Promise<void> {
// 复制链接本身即可:链接已含取件码(/s/<code>),再拼一遍会导致
// 「/s/XVR3H XVR3H」被整串当码查询而 404v3.1.2 遗留缺陷)
const text = value === 'link' ? link.value : props.code
const ok = await copyText(text)
if (ok) {
copied.value = value
toast.success(value === 'link' ? t('result.linkCopied') : t('result.codeCopied'))
setTimeout(() => (copied.value = null), 1600)
} else {
toast.error(t('result.copyFailed'))
}
}
const expireText = computed(() =>
props.expireStyle === 'forever' ? t('result.forever') : `${props.expireValue ?? '-'} ${expireStyleLabel(props.expireStyle ?? '')}`,
)
</script>
<template>
<div class="card result-card">
<div class="result-head">
<span class="badge badge-success">{{ t('result.badge') }}</span>
<span v-if="name" class="result-name">{{ name }}</span>
</div>
<div class="field">
<label>{{ t('result.code') }}</label>
<button class="code-display code-copy" type="button" :title="t('result.clickCopyCode')" @click="copy('code')">
{{ code }}
</button>
</div>
<div class="field">
<label>{{ t('result.link') }}</label>
<div class="link-row">
<input class="input input-mono" :value="link" readonly @focus="($event.target as HTMLInputElement).select()" />
<button class="btn btn-ghost" type="button" @click="copy('link')">
{{ copied === 'link' ? t('common.copied') : t('result.copyLink') }}
</button>
</div>
</div>
<div class="result-meta">
<span v-if="expireStyle">{{ t('result.expires', { value: expireText }) }}</span>
</div>
<p class="hint" style="margin-top: 10px">{{ t('result.hint') }}</p>
</div>
</template>
<style scoped>
.result-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.result-name { font-weight: 600; word-break: break-all; }
.link-row { display: flex; gap: 8px; }
.link-row .input { flex: 1; }
.result-meta { display: flex; align-items: center; gap: 6px; color: var(--c-text-2); font-size: 13px; flex-wrap: wrap; }
.code-copy {
/* 仅恢复 button 元素为块级全宽,视觉完全沿用 .code-display 原样式(不缩小) */
display: block;
width: 100%;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
transition: filter 0.15s;
}
.code-copy:hover { filter: brightness(0.96); }
.code-copy:active { filter: brightness(0.92); }
</style>
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
/**
* 顶部导航(需求⑥:只留主导航——API 文档/OpenAPI/管理后台移至页脚):
* - 需求②:主题三态切换(亮/暗/跟随系统)+ localStorage 记忆
* - 需求④:本地打包 Logoconfig logo_url 自定义仍优先)
* - 需求⑩:语言切换器 + localStorage 记忆
*/
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfigStore } from '@/stores/config'
import { useTheme, THEME_MODES, type ThemeMode } from '@/theme'
import { setLocale, currentLocale, type Locale } from '@/i18n'
const { t } = useI18n()
const config = useConfigStore()
const { mode, setMode } = useTheme()
// 需求⑥:主导航只保留「分享」;其余链接(文档/OpenAPI/后台)下沉页脚
const links = computed(() => [{ to: '/', label: t('nav.home'), match: (p: string) => p === '/' }])
function isActive(to: { match: (p: string) => boolean }): boolean {
return to.match(location.pathname)
}
const themeIcons: Record<ThemeMode, string> = { light: '☀️', dark: '🌙', system: '💻' }
const themeLabels = computed<Record<ThemeMode, string>>(() => ({
light: t('theme.light'),
dark: t('theme.dark'),
system: t('theme.system'),
}))
const langLabel = computed(() => (currentLocale() === 'zh-CN' ? '中文' : 'English'))
function switchLang(): void {
const next: Locale = currentLocale() === 'zh-CN' ? 'en-US' : 'zh-CN'
setLocale(next)
}
</script>
<template>
<header class="site-nav">
<RouterLink class="brand" to="/" :title="t('nav.homeTitle', { name: config.displayName })">
<!-- 需求④本地打包 Logo管理端自定义 logo_url 优先 -->
<img :src="config.displayLogoUrl" alt="Logo" @error="($event.target as HTMLImageElement).style.visibility = 'hidden'" />
<span class="brand-name">{{ config.displayName }}</span>
</RouterLink>
<nav class="nav-links" :aria-label="t('nav.mainNav')">
<RouterLink v-for="l in links" :key="l.to" :to="l.to" :class="{ 'router-link-active': isActive(l) }">
{{ l.label }}
</RouterLink>
</nav>
<div class="nav-controls">
<!-- 需求②主题三态 -->
<div class="theme-seg" role="radiogroup" :aria-label="t('theme.label')">
<button
v-for="m in THEME_MODES"
:key="m"
type="button"
role="radio"
:aria-checked="mode === m"
:class="{ active: mode === m }"
:title="themeLabels[m]"
@click="setMode(m)"
>
<span class="nav-control-icon" aria-hidden="true">{{ themeIcons[m] }}</span>
</button>
</div>
<!-- 需求⑩语言切换 -->
<button class="nav-control" type="button" :title="t('lang.label')" @click="switchLang">
<span class="nav-control-icon" aria-hidden="true">🌐</span>
<span>{{ langLabel }}</span>
</button>
</div>
</header>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { useToastStore } from '@/stores/toast'
const toast = useToastStore()
</script>
<template>
<div class="toast-host" aria-live="polite">
<div
v-for="t in toast.items"
:key="t.id"
class="toast"
:class="`toast-${t.type}`"
role="status"
@click="toast.dismiss(t.id)"
>
<span class="toast-icon" aria-hidden="true">{{ t.type === 'success' ? '✅' : t.type === 'error' ? '⚠️' : '️' }}</span>
<span>{{ t.text }}</span>
</div>
</div>
</template>
+88
View File
@@ -0,0 +1,88 @@
/**
* 文档内容源(需求⑤:站内 /docs 页面渲染 docs/api/*.md
* 双通道:
* A. 构建时嵌入 —— import.meta.glob 静态导入 ../docs/api/*.md 原文(embed 部署/离线可用);
* 文档由 tech-writer 任务产出,写入 docs/api/ 后重新构建前端即生效。
* B. 运行时 fetch —— GET ${API_BASE}/docs/api/<slug>.md(后端若挂载 docs 目录则优先最新内容)。
* 约定:每个 md 的一级标题(# ...)作为侧边栏章节名;排序按 ORDER 列表(tech-writer 文件命名对齐)。
*/
// 注意:本文件位于 web/src/docs/,文档源在项目根 docs/api/ → 相对路径需上溯三级。
// docs/ 目录由 tech-writer 任务产出;为空时 glob 返回空对象,/docs 页展示空态。
const markdownFiles = import.meta.glob('../../../docs/api/*.md', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>
export interface DocEntry {
slug: string
title: string
/** 构建时嵌入的 markdown 原文;无则运行时 fetch */
embedded: string | null
/** 侧边栏排序权重 */
order: number
}
/** 侧边栏章节顺序(对齐 t4 交付清单;未列出的文件按文件名排序追加在最后) */
const ORDER = ['README', 'share', 'chunk', 'presign', 'admin', 'audit', 'errors', 'config', 'logo', 'webdav', 'env']
function slugFromPath(p: string): string {
const base = p.split('/').pop() ?? p
return base.replace(/\.md$/, '')
}
function titleOf(md: string, slug: string): string {
const m = /^#\s+(.+)$/m.exec(md)
return m ? m[1].trim() : slug
}
export const docs: DocEntry[] = Object.entries(markdownFiles)
.map(([path, content]) => {
const slug = slugFromPath(path)
const orderIdx = ORDER.indexOf(slug)
return {
slug,
title: titleOf(content, slug),
embedded: content,
order: orderIdx >= 0 ? orderIdx : 100 + slug.localeCompare(''),
}
})
.sort((a, b) => a.order - b.order || a.slug.localeCompare(b.slug))
export const hasEmbeddedDocs = docs.length > 0
/** OpenAPI 规范:构建时嵌入(yaml 原文),供 /openapi 页 Swagger UI 使用 */
const openApiFiles = import.meta.glob('../../../docs/openapi.yaml', {
query: '?raw',
import: 'default',
eager: true,
}) as Record<string, string>
export const embeddedOpenApiYaml: string | null = Object.values(openApiFiles)[0] ?? null
/** 取单篇文档内容:优先嵌入,其次运行时 fetch */
export async function loadDoc(doc: DocEntry): Promise<string> {
if (doc.embedded !== null) return doc.embedded
const { API_BASE } = await import('@/api/paths')
const url = `${API_BASE}/docs/api/${encodeURIComponent(doc.slug)}.md`
const res = await fetch(url)
if (!res.ok) throw new Error(`文档加载失败(HTTP ${res.status}`)
return res.text()
}
/** 运行时扫描远端文档列表(后端挂载 docs/api/ 时可见嵌入集之外的新文档) */
export async function discoverRemoteDocs(): Promise<DocEntry[]> {
try {
const { API_BASE } = await import('@/api/paths')
const res = await fetch(`${API_BASE}/docs/api/index.json`)
if (!res.ok) return []
const list = (await res.json()) as { slug: string; title?: string }[]
const known = new Set(docs.map((d) => d.slug))
return list
.filter((d) => d.slug && !known.has(d.slug))
.map((d) => ({ slug: d.slug, title: d.title ?? d.slug, embedded: null, order: 200 }))
} catch {
return []
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* vue-i18n 装配(需求⑩):
* - legacy:falseComposition API 模式)
* - 默认语言:navigator.languagezh 开头 → zh-CN,其余 → en-US
* - localStorage 记忆用户选择(键 fcb_locale
*/
import { createI18n } from 'vue-i18n'
import zhCN from '@/locales/zh-CN'
import enUS from '@/locales/en-US'
export type Locale = 'zh-CN' | 'en-US'
export const LOCALES: Locale[] = ['zh-CN', 'en-US']
const STORAGE_KEY = 'fcb_locale'
function readStoredLocale(): Locale | null {
try {
const v = localStorage.getItem(STORAGE_KEY)
return v === 'zh-CN' || v === 'en-US' ? v : null
} catch {
return null
}
}
/** 需求⑩:默认跟随 navigator.language —— zh 开头 → zh-CN,其余 → en-US */
function detectLocale(): Locale {
const saved = readStoredLocale()
if (saved) return saved
const lang = (typeof navigator !== 'undefined' ? navigator.language : 'en') ?? 'en'
return lang.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en-US'
}
function persist(locale: Locale): void {
try {
localStorage.setItem(STORAGE_KEY, locale)
} catch {
/* localStorage 不可用时忽略 */
}
}
export const i18n = createI18n({
legacy: false,
locale: detectLocale(),
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'en-US': enUS,
},
// 生产环境关闭缺失告警,避免控制台噪音
missingWarn: false,
fallbackWarn: false,
})
/** 当前语言(响应式 Composer */
export function currentLocale(): Locale {
return (i18n.global.locale.value ?? 'zh-CN') as Locale
}
/** 切换语言并记忆(需求⑩:导航栏切换器 + localStorage */
export function setLocale(locale: Locale): void {
i18n.global.locale.value = locale
document.documentElement.lang = locale
persist(locale)
}
/** 全局 t(非组件上下文:router/store/utils 用) */
export const tGlobal = i18n.global.t
+409
View File
@@ -0,0 +1,409 @@
/**
* English catalog. Key structure mirrors zh-CN.ts;
* add new copy to zh-CN first, then sync here.
*/
export default {
app: {
tagline: 'File Drop',
description: 'A ready-to-use file sharing service',
},
nav: {
home: 'Share',
docs: 'API Docs',
openapi: 'OpenAPI',
admin: 'Admin',
homeTitle: '{name} — Home',
mainNav: 'Main navigation',
},
theme: {
label: 'Theme',
light: 'Light',
dark: 'Dark',
system: 'System',
},
lang: {
label: 'Language',
},
footer: {
linkNav: 'Footer links',
docs: 'API Docs',
openapi: 'OpenAPI',
admin: 'Admin',
copyright: '© {year} {name}',
},
notify: {
title: 'System Notice',
close: 'Got it',
},
common: {
loading: 'Loading…',
cancel: 'Cancel',
save: 'Save',
search: 'Search',
refresh: 'Refresh',
copy: 'Copy',
copied: 'Copied',
copyFailed: 'Copy failed',
close: 'Close',
actions: 'Actions',
all: 'All',
query: 'Query',
reset: 'Reset',
previousPage: 'Previous',
nextPage: 'Next',
pagerInfo: '{total} records · page {page}/{pages}',
text: 'Text',
file: 'File',
success: 'Success',
failed: 'Failed',
denied: 'Denied',
none: '-',
},
time: {
forever: 'Never expires',
permanent: 'Permanent',
expired: 'Expired',
lessThanMinute: 'less than a minute',
minutes: '{n} min',
hoursMinutes: '{h} h {m} min',
daysHours: '{d} d {h} h',
},
expireStyle: {
day: 'Days',
hour: 'Hours',
minute: 'Minutes',
count: 'Times',
forever: 'Forever',
},
home: {
heroTitle: '{name} · File Drop',
heroDesc: 'No signup — share text or files and hand over a pickup code',
pickupPlaceholder: 'Enter a pickup code',
pickupButton: 'Pick up',
pickupRequired: 'Please enter a pickup code',
tabText: 'Share text',
tabFile: 'Share file',
textContent: 'Text content',
textPlaceholder: 'Paste the text or code snippet to share…',
textBytes: '{bytes} / 222 KB (use file sharing for larger content)',
textTooLong: 'Content too long (over 222KB) — please share it as a file instead',
textRequired: 'Enter the text to share',
customCode: 'Custom pickup code (optional)',
customCodeHint: 'Leave empty for random; 4-8 letters/digits',
customCodeInvalid: 'Pickup code must be 4-8 letters or digits',
customCodeTaken: 'This pickup code is already taken',
generateCode: 'Generate code',
fileRequired: 'Please choose a file to share',
fileTooLarge: 'File exceeds the size limit (max {size})',
chunkedUploading: 'Chunked upload',
uploading: 'Uploading',
uploadingDots: 'Uploading…',
uploadAndShare: 'Upload & generate code',
uploadDisabled: 'Guest uploads are disabled. Please contact the administrator if you need to share.',
shareAnother: 'Share another one',
textShared: 'Text shared',
fileShared: 'File shared',
uploadCancelled: 'Upload cancelled',
shareFailed: 'Share failed, please try again later',
uploadFailed: 'Upload failed, please retry',
rateLimited: 'Too many requests, please slow down',
notInitialized: 'System is not initialized yet. An administrator must finish the setup first.',
},
result: {
badge: 'Shared',
code: 'Pickup code',
link: 'Pickup link',
copyLink: 'Copy link',
copyLinkCode: 'Copy link & code',
copyCode: 'Copy code',
codeCopied: 'Pickup code copied',
linkCopied: 'Pickup link copied',
linkCodeCopied: 'Link and code copied',
clickCopyCode: 'Click to copy pickup code',
expires: 'Expires in: {value}',
forever: 'Forever',
hint: 'Send the code or link to the recipient; they can pick it up from the home page.',
copyFailed: 'Copy failed — please select the text manually',
},
pickup: {
emptyCode: 'Pickup code is empty',
querying: 'Looking up code {code} …',
failed: 'Pickup failed',
failedDefault: 'Pickup failed, please try again later',
notFound: 'Code not found or the share has expired',
confirmHint: 'Double-check the code, or ask the sender to share it again',
retryPlaceholder: 'Enter another pickup code',
retryButton: 'Try again',
remainingUnlimited: 'Unlimited',
remainingCount: '{n} left',
expireAt: 'Expires: {time}',
loadingText: 'Fetching content…',
copyContent: 'Copy content',
downloadTxt: 'Download as .txt',
downloaded: 'Download complete',
downloadFailed: 'Download failed, please retry',
copied: 'Content copied',
sizeUsed: 'Size {size} · picked up {n} times',
downloading: 'Downloading {percent}%',
downloadFile: 'Download ({size})',
},
expire: {
value: 'Amount',
label: 'Expires in',
foreverOption: 'Never expires',
countOption: 'After N pickups',
countHint: 'The share becomes invalid after the given number of pickups',
timeHint: 'Valid for {value} {unit}',
foreverHint: 'The share stays valid until an administrator deletes it',
maxSecondsHint: 'At most {value}',
maxCountHint: 'At most {n} pickups',
},
drop: {
aria: 'Choose or drop a file',
zone: 'Click to choose or drop a file here',
maxSize: 'Max {size} per file',
noLimit: 'A pickup code is generated after upload',
remove: 'Remove',
tooLarge: 'File size {size} exceeds the limit {limit}',
typeHint: 'Allowed types: {types}',
},
docs: {
searchPlaceholder: 'Search documentation…',
notGenerated: 'Docs not generated yet',
buildHint: 'They are collected from docs/api/*.md at build time',
noMatch: 'No matching sections',
tocTitle: 'On this page',
loading: 'Loading document…',
preparing: 'API docs are on the way',
preparingHint:
'Sources live in the project docs/api/ directory (each .md is one section). Rebuild the frontend to embed them for offline use.',
emptyContent: 'Document is empty',
loadFailed: 'Failed to load document "{title}"',
sidebar: 'Documentation sections',
},
openapi: {
title: 'OpenAPI 3.0 Specification',
statusOk: 'Loaded',
statusError: 'Failed to load spec',
statusLoading: 'Loading…',
source: 'Source: {source}',
sourceEmbedded: 'Embedded docs/openapi.yaml at build time',
notAvailable: 'openapi.yaml is not generated or cannot be accessed',
notAvailableHint:
'The spec file lives in the project docs/openapi.yaml. Rebuilding the frontend embeds it; you can also deploy it to {url} for runtime loading.',
},
notFound: {
title: 'Page not found',
desc: 'The address may have changed',
back: 'Back home',
},
admin: {
login: {
title: 'Administrator Sign-in',
subtitle: '{name} · Admin Console',
password: 'Admin password',
passwordPlaceholder: 'Enter the admin password',
submit: 'Sign in',
wrongPassword: 'Incorrect password',
failed: 'Sign-in failed, please try again later',
required: 'Please enter the admin password',
hint: 'The password is configured by the deployer via env vars or system settings; repeated failures trigger IP rate limiting.',
},
nav: {
title: 'Admin',
files: 'Files',
audit: 'Audit Log',
settings: 'Settings',
logout: 'Sign out',
menu: 'Admin menu',
loggedOut: 'Signed out',
},
files: {
title: 'File Management',
totalRecords: '{total} shares in total',
searchPlaceholder: 'Search code / file name',
batchDelete: 'Delete selected',
batchDeleteWithCount: 'Delete selected ({count})',
deleteSelectedTitle: 'Delete {count} selected items',
selectFirst: 'Select rows first',
loading: 'Loading…',
empty: 'No shares yet',
loadFailed: 'Failed to load the file list',
colCode: 'Code',
colName: 'Name',
colType: 'Type',
colSize: 'Size',
colUsed: 'Picked',
colRemaining: 'Remaining',
colExpireAt: 'Expires',
colStatus: 'Status',
colCreatedAt: 'Created',
remainingUnlimited: '∞',
remainingCount: '{n} left',
statusValid: 'Active',
statusExpired: 'Expired',
copyCode: 'Copy code',
copyLink: 'Copy link',
edit: 'Edit',
fetchText: 'Fetch text',
delete: 'Delete',
confirmDelete: 'Delete "{name}"? This cannot be undone.',
confirmBatchDelete: 'Delete {count} selected shares? This cannot be undone.',
deleteSuccess: 'Deleted',
batchDeleteSuccess: 'Batch deleted',
deleteFailed: 'Delete failed',
batchDeleteFailed: 'Batch delete failed',
nothingChanged: 'Nothing changed',
updateSuccess: 'Updated',
updateFailed: 'Update failed',
fetchTextFailed: 'Failed to fetch content (the share may have expired)',
linkCopied: 'Pickup link copied',
codeCopied: 'Pickup code copied',
editModalTitle: 'Edit share',
expireAtHint: 'Expires at (leave empty for never)',
expireCountHint: 'Remaining pickups (-1 for unlimited)',
},
audit: {
title: 'Audit Log',
subtitle: 'Upload / download events: time, IP, UA, device, result, bytes and duration',
action: 'Action',
result: 'Result',
actionUpload: 'Upload',
actionDownload: 'Download',
filterIp: 'IP',
filterStart: 'Start time',
filterEnd: 'End time',
empty: 'No audit records yet (only upload / download actions are recorded)',
loadFailed: 'Failed to load the audit log',
colTime: 'Time',
colAction: 'Action',
colResult: 'Result',
colFile: 'File',
colCode: 'Code',
colBytes: 'Bytes',
colIp: 'IP',
colDevice: 'Device',
colDuration: 'Duration',
colUaError: 'UA / Error',
},
settings: {
title: 'System Settings',
subtitle: 'Site name and branding (custom values win; leave empty to restore built-in defaults)',
restoreDefaults: 'Restore defaults',
restoreDefaultsDone: 'Defaults filled in — click save to apply',
loading: 'Loading settings…',
loadFailed: 'Failed to load settings',
sectionBasic: 'Basic',
siteName: 'Site name · site_name',
siteNameHint: 'Shown in the nav bar, login page and browser title',
siteDomain: 'Public site domain',
siteDomainHint: 'http(s)://host[:port], no path; leave empty to use the current address in share links',
sectionLogo: 'Nav logo',
logoUrl: 'Logo image URL · logo_url',
uploadImage: 'Upload image',
logoHint: 'Enter a URL or upload a local image (≤256KB, stored inline); leave empty for the built-in default',
imageTooLarge: 'Image exceeds 256KB — compress it or paste an image URL instead',
imageLoaded: 'Image loaded — click save to apply site-wide',
imageReadFailed: 'Failed to read the image',
navPreview: 'Nav bar preview:',
sectionFavicon: 'Browser favicon',
faviconUrl: 'Favicon URL · favicon_url',
faviconHint: 'Use a square PNG/ICO; leave empty for the built-in default',
faviconPreviewHint: 'Browser tab icon (applied after saving and refreshing)',
saveAll: 'Save settings (applies site-wide)',
saved: 'Settings saved site-wide',
saveFailed: 'Save failed',
sectionPassword: 'Change admin password',
passwordHint: 'After saving, all signed-in sessions are invalidated and you must sign in again',
oldPassword: 'Old password',
newPassword: 'New password (at least 6 characters)',
confirmPassword: 'Confirm new password',
pwdRequired: 'Please fill in the old and new passwords',
pwdTooShort: 'The new password must be at least 6 characters',
pwdMismatch: 'The two passwords do not match',
pwdChanged: 'Password changed — please sign in again with the new password',
pwdChangeFailed: 'Change failed',
pwdWrong: 'Old password is incorrect',
// —— v2 sections ——
sectionBackground: 'Background image',
backgroundUrl: 'Background URL · background_url',
backgroundHint:
'http(s) image URL, data:image image or site-relative path (≤2048 chars); leave empty for the theme default',
sectionFooter: 'Footer',
footerText: 'Footer text · footer_text',
footerTextHint: 'Shown at the page bottom as plain text (≤2000 chars); leave empty for the default tagline',
footerBeian: 'ICP filing number · footer_beian',
footerBeianHint: 'e.g. 京ICP备2024xxxxxx号-1 (≤128 chars)',
sectionNotify: 'System notice',
notifyEnabled: 'Show floating notice · notify_enabled',
notifyTitle: 'Notice title · notify_title',
notifyTitleHint: 'Leave empty for the default title "System Notice" (≤128 chars)',
notifyContent: 'Notice content · notify_content',
notifyContentHint: 'Controlled HTML such as <a> is allowed (≤2000 chars)',
sectionSavePolicy: 'Save policy',
maxSaveSeconds: 'Max save seconds · max_save_seconds',
maxSaveSecondsHint: '0 = unlimited (server default 7-day fallback), max {max} seconds (365 days)',
maxSaveCount: 'Max pickup count · max_save_count',
maxSaveCountHint: '0 = unlimited, max {max}',
sectionStorage: 'Storage policy',
maxFileSize: 'Max file size · max_file_size (bytes)',
maxFileSizeHint: '0 = fall back to uploadSize (currently {fallback}), max {max} bytes (10 GiB)',
allowedFileTypes: 'Allowed types · allowed_file_types',
allowedFileTypesHint: 'Comma separated: extensions (jpg) or MIME (image/*); * means no limit',
sectionUploadRate: 'Upload rate limit',
uploadCount: 'Uploads per window · uploadCount',
uploadCountHint: 'Min 1, max {max}',
uploadMinute: 'Window length (minutes) · uploadMinute',
uploadMinuteHint: 'Min 1, max {max}',
// —— v3: friendly units + storage engine ——
unitHour: 'Hour(s)',
unitDay: 'Day(s)',
unitMB: 'MB',
unitGB: 'GB',
maxSaveTime: 'Max save time · max_save_seconds',
maxSaveTimeHint: '0 = unlimited (server default 7-day fallback), max 365 days',
saveTimeUnlimited: 'Unlimited (0)',
maxFileSizeFriendly: 'Max file size · max_file_size',
maxFileSizeHintV3: '0 = fall back to uploadSize (current {fallback}), max 10 GB',
sizeUnlimited: 'Unlimited (0)',
sectionEngine: 'Storage engine',
engineCurrent: 'Current engine',
engineLocal: 'Local storage',
engineWebdav: 'WebDAV',
engineS3: 'S3 object storage',
engineSwitch: 'Switch to this engine',
engineSwitching: 'Switching…',
engineSwitchOk: 'Storage engine switched to {engine}',
engineSwitchFail: 'Switch failed (previous engine kept)',
engineParamsTitle: 'Engine parameters',
engineParamsSaved: 'Engine parameters saved',
localRoot: 'Storage root · local_storage_path',
localRootHint: 'Empty = system default data directory; applies to new writes',
webdavUrl: 'Server URL · webdav_url',
webdavUrlHint: 'e.g. https://dav.example.com/dav/',
webdavRoot: 'Remote root · webdav_root_path',
webdavRootHint: 'Remote base directory (created recursively if missing)',
webdavUser: 'Username · webdav_username',
webdavPass: 'Password · webdav_password',
secretKeepHint: 'Empty or ****** = keep unchanged',
s3Endpoint: 'Endpoint · s3_endpoint_url',
s3EndpointHint: 'e.g. https://s3.example.com:9000 (leave empty for AWS)',
s3Bucket: 'Bucket · s3_bucket_name',
s3Region: 'Region · s3_region_name',
s3Ak: 'AccessKeyID · s3_access_key_id',
s3Sk: 'SecretAccessKey · s3_secret_access_key',
s3Token: 'Session token · aws_session_token (optional)',
s3Style: 'Addressing style · s3_addressing_style',
styleAuto: 'auto',
stylePath: 'path (typical for MinIO)',
styleVirtual: 'virtual-hosted',
engineParamsSave: 'Save engine parameters',
approxSize: '≈ {size}',
},
},
}
+403
View File
@@ -0,0 +1,403 @@
/**
* 中文文案(默认语言)。键结构与其他语言包保持一致;
* 新增文案请先加到这里,再同步 en-US.ts。
*/
export default {
app: {
tagline: '文件快传',
description: '开箱即用的文件快传系统',
},
nav: {
home: '分享',
docs: 'API 文档',
openapi: 'OpenAPI',
admin: '管理后台',
homeTitle: '{name} — 首页',
mainNav: '主导航',
},
theme: {
label: '主题',
light: '浅色',
dark: '深色',
system: '跟随系统',
},
lang: {
label: '语言',
},
footer: {
linkNav: '页脚链接',
docs: 'API 文档',
openapi: 'OpenAPI',
admin: '管理后台',
copyright: '© {year} {name}',
},
notify: {
title: '系统通知',
close: '知道了',
},
common: {
loading: '加载中…',
cancel: '取消',
save: '保存',
search: '搜索',
refresh: '刷新',
copy: '复制',
copied: '已复制',
copyFailed: '复制失败',
close: '关闭',
actions: '操作',
all: '全部',
query: '查询',
reset: '重置',
previousPage: '上一页',
nextPage: '下一页',
pagerInfo: '共 {total} 条 · 第 {page}/{pages} 页',
text: '文本',
file: '文件',
success: '成功',
failed: '失败',
denied: '拒绝',
none: '-',
},
time: {
forever: '永久有效',
permanent: '永久',
expired: '已过期',
lessThanMinute: '不足 1 分钟',
minutes: '{n} 分钟',
hoursMinutes: '{h} 小时 {m} 分',
daysHours: '{d} 天 {h} 小时',
},
expireStyle: {
day: '天',
hour: '小时',
minute: '分钟',
count: '次数',
forever: '永久',
},
home: {
heroTitle: '{name} · 文件快传',
heroDesc: '无需注册,文本文件一键分享,取件码即可领取',
pickupPlaceholder: '输入取件码直接领取',
pickupButton: '取 件',
pickupRequired: '请输入取件码',
tabText: '分享文本',
tabFile: '分享文件',
textContent: '文本内容',
textPlaceholder: '粘贴要分享的文本、代码片段…',
textBytes: '{bytes} / 222 KB(超出请改用文件分享)',
textTooLong: '内容过多(超过 222KB),建议采用文件形式分享',
textRequired: '请输入要分享的文本内容',
customCode: '自定义提取码(可选)',
customCodeHint: '留空随机生成;4-8 位字母或数字',
customCodeInvalid: '提取码须为 4-8 位字母或数字',
customCodeTaken: '该提取码已被占用,请换一个',
generateCode: '生成取件码',
fileRequired: '请选择要分享的文件',
fileTooLarge: '文件大小超过限制(最大 {size}',
chunkedUploading: '分片上传中',
uploading: '上传中',
uploadingDots: '上传中…',
uploadAndShare: '上传并生成取件码',
uploadDisabled: '管理员已关闭访客上传功能,如需分享请联系管理员',
shareAnother: '再分享一个',
textShared: '文本分享成功',
fileShared: '文件分享成功',
uploadCancelled: '上传已取消',
shareFailed: '分享失败,请稍后重试',
uploadFailed: '上传失败,请重试',
rateLimited: '操作过于频繁,请稍后再试',
notInitialized: '系统尚未初始化,请管理员先完成初始化配置',
},
result: {
badge: '分享成功',
code: '取件码',
link: '取件链接',
copyLink: '复制链接',
copyLinkCode: '复制链接和提取码',
copyCode: '复制取件码',
codeCopied: '取件码已复制',
linkCopied: '取件链接已复制',
linkCodeCopied: '链接和提取码已复制',
clickCopyCode: '点击复制提取码',
expires: '有效期:{value}',
forever: '永久',
hint: '把取件码或链接发给对方,对方在首页输入取件码即可领取。',
copyFailed: '复制失败,请手动选择复制',
},
pickup: {
emptyCode: '取件码为空',
querying: '正在查询取件码 {code} …',
failed: '取件失败',
failedDefault: '取件失败,请稍后重试',
notFound: '取件码不存在或分享已过期',
confirmHint: '请确认取件码是否正确,或联系分享人重新发送',
retryPlaceholder: '输入其他取件码',
retryButton: '重新取件',
remainingUnlimited: '不限次数',
remainingCount: '剩余 {n} 次',
expireAt: '过期时间:{time}',
loadingText: '正在获取内容…',
copyContent: '复制内容',
downloadTxt: '下载为 .txt',
downloaded: '下载完成',
downloadFailed: '下载失败,请重试',
copied: '内容已复制',
sizeUsed: '大小 {size} · 已被领取 {n} 次',
downloading: '下载中 {percent}%',
downloadFile: '下载文件({size}',
},
expire: {
value: '数值',
label: '有效期',
foreverOption: '永久有效',
countOption: '按次数',
countHint: '分享在被领取指定次数后失效',
timeHint: '有效期 {value} {unit}',
foreverHint: '分享将一直有效,直到管理员删除',
maxSecondsHint: '最长 {value}',
maxCountHint: '最多 {n} 次',
},
drop: {
aria: '选择或拖拽文件',
zone: '点击选择或拖拽文件到此处',
maxSize: '单文件最大 {size}',
noLimit: '上传后自动生成取件码',
remove: '移除',
tooLarge: '文件大小 {size} 超过限制 {limit}',
typeHint: '仅支持 {types}',
},
docs: {
searchPlaceholder: '检索文档内容…',
notGenerated: '文档尚未生成',
buildHint: '构建时将从 docs/api/*.md 自动收录',
noMatch: '没有匹配的章节',
tocTitle: '本页目录',
loading: '加载文档…',
preparing: 'API 文档筹备中',
preparingHint:
'文档源位于项目 docs/api/ 目录(每个 .md 一级标题作为章节名)。重新构建前端后,文档将内嵌到页面中离线可用。',
emptyContent: '文档内容为空',
loadFailed: '文档「{title}」加载失败',
sidebar: '文档章节',
},
openapi: {
title: 'OpenAPI 3.0 接口规范',
statusOk: '加载成功',
statusError: '规范加载失败',
statusLoading: '加载中…',
source: '来源:{source}',
sourceEmbedded: '构建内嵌 docs/openapi.yaml',
notAvailable: 'openapi.yaml 尚未生成或无法访问',
notAvailableHint:
'规范文件位于项目 docs/openapi.yaml。重新构建前端会将其内嵌;也可将文件部署到 {url} 供运行时加载。',
},
notFound: {
title: '页面不存在',
desc: '你访问的地址可能已变更',
back: '回到首页',
},
admin: {
login: {
title: '管理员登录',
subtitle: '{name} · 管理后台',
password: '管理员密码',
passwordPlaceholder: '请输入管理员密码',
submit: '登 录',
wrongPassword: '密码错误',
failed: '登录失败,请稍后重试',
required: '请输入管理员密码',
hint: '密码由部署方在环境变量或系统设置中配置;连续输错会触发 IP 限流保护。',
},
nav: {
title: '管理后台',
files: '文件管理',
audit: '审计日志',
settings: '系统设置',
logout: '退出登录',
menu: '后台菜单',
loggedOut: '已退出登录',
},
files: {
title: '文件管理',
totalRecords: '共 {total} 个分享记录',
searchPlaceholder: '搜索取件码 / 文件名',
batchDelete: '批量删除',
batchDeleteWithCount: '批量删除({count}',
deleteSelectedTitle: '删除选中的 {count} 项',
selectFirst: '先勾选要删除的行',
loading: '加载中…',
empty: '暂无分享记录',
loadFailed: '文件列表加载失败',
colCode: '取件码',
colName: '名称',
colType: '类型',
colSize: '大小',
colUsed: '已领取',
colRemaining: '剩余',
colExpireAt: '过期时间',
colStatus: '状态',
colCreatedAt: '创建时间',
remainingUnlimited: '不限',
remainingCount: '{n} 次',
statusValid: '有效',
statusExpired: '已过期',
copyCode: '复制码',
copyLink: '复制链接',
edit: '编辑',
fetchText: '取内容',
delete: '删除',
confirmDelete: '确认删除分享「{name}」?该操作不可恢复。',
confirmBatchDelete: '确认删除选中的 {count} 个分享?该操作不可恢复。',
deleteSuccess: '删除成功',
batchDeleteSuccess: '批量删除成功',
deleteFailed: '删除失败',
batchDeleteFailed: '批量删除失败',
nothingChanged: '没有修改任何字段',
updateSuccess: '更新成功',
updateFailed: '更新失败',
fetchTextFailed: '内容获取失败(分享可能已过期)',
linkCopied: '取件链接已复制',
codeCopied: '取件码已复制',
editModalTitle: '编辑分享',
expireAtHint: '过期时间(留空表示永久)',
expireCountHint: '剩余可领取次数(-1 表示不限)',
},
audit: {
title: '审计日志',
subtitle: '记录上传 / 下载动作:时间、IP、UA、设备、结果、字节数与耗时',
action: '动作',
result: '结果',
actionUpload: '上传',
actionDownload: '下载',
filterIp: 'IP',
filterStart: '开始时间',
filterEnd: '结束时间',
empty: '暂无审计记录(审计仅记录上传 / 下载动作)',
loadFailed: '审计日志加载失败',
colTime: '时间',
colAction: '动作',
colResult: '结果',
colFile: '文件',
colCode: '取件码',
colBytes: '字节数',
colIp: 'IP',
colDevice: '设备',
colDuration: '耗时',
colUaError: 'UA / 错误',
},
settings: {
title: '系统设置',
subtitle: '站点名称与 Logo(自定义优先,留空恢复内置默认)',
restoreDefaults: '恢复默认值',
restoreDefaultsDone: '已填回默认值,点击保存生效',
loading: '加载配置中…',
loadFailed: '配置读取失败',
sectionBasic: '基本',
siteName: '站点名称 site_name',
siteNameHint: '显示在导航栏、登录页与浏览器标题',
siteDomain: '网站对外域名',
siteDomainHint: 'http(s)://域名[:端口],不带路径;留空则分享链接用当前访问地址',
sectionLogo: '导航 Logo',
logoUrl: 'Logo 图片地址 logo_url',
uploadImage: '上传图片',
logoHint: '支持填写 URL 或上传本地图片(≤256KB,转存为内嵌数据);留空使用内置默认',
imageTooLarge: '图片超过 256KB,请压缩后重试或直接填写图片 URL',
imageLoaded: '图片已载入,点击保存后全站生效',
imageReadFailed: '图片读取失败',
navPreview: '导航栏实际效果:',
sectionFavicon: '浏览器图标 Favicon',
faviconUrl: 'Favicon 地址 favicon_url',
faviconHint: '建议使用 PNG/ICO 方形图标;留空使用内置默认',
faviconPreviewHint: '浏览器标签页图标(保存后刷新页面生效)',
saveAll: '保存设置(全站生效)',
saved: '设置已保存,全站生效',
saveFailed: '保存失败',
sectionPassword: '修改管理员密码',
passwordHint: '保存后所有已登录会话失效,需重新登录',
oldPassword: '旧密码',
newPassword: '新密码(至少 6 位)',
confirmPassword: '确认新密码',
pwdRequired: '请填写旧密码与新密码',
pwdTooShort: '新密码至少 6 位',
pwdMismatch: '两次输入的新密码不一致',
pwdChanged: '密码已修改,请使用新密码重新登录',
pwdChangeFailed: '修改失败',
pwdWrong: '旧密码错误',
// —— v2 新增分区 ——
sectionBackground: '背景图',
backgroundUrl: '背景图地址 background_url',
backgroundHint: '支持 http(s) 图片地址、data:image 图片或站内相对路径(≤2048 字符);留空使用主题默认',
sectionFooter: '页脚',
footerText: '页脚文案 footer_text',
footerTextHint: '展示在页面底部,支持纯文本(≤2000 字符);留空显示默认标语',
footerBeian: '备案号 footer_beian',
footerBeianHint: '如 京ICP备2024xxxxxx号-1(≤128 字符)',
sectionNotify: '系统通知',
notifyEnabled: '启用右上角通知 notify_enabled',
notifyTitle: '通知标题 notify_title',
notifyTitleHint: '留空显示默认标题「系统通知」(≤128 字符)',
notifyContent: '通知内容 notify_content',
notifyContentHint: '支持 <a> 等受控 HTML(≤2000 字符)',
sectionSavePolicy: '保存策略',
maxSaveSeconds: '最长保存秒数 max_save_seconds',
maxSaveSecondsHint: '0 = 不限制(服务端默认 7 天兜底),最大 {max} 秒(365 天)',
maxSaveCount: '最大可取次数 max_save_count',
maxSaveCountHint: '0 = 不限制,最大 {max} 次',
sectionStorage: '存储策略',
maxFileSize: '单文件上限 max_file_size(字节)',
maxFileSizeHint: '0 = 回落 uploadSize(当前 {fallback}),最大 {max} 字节(10 GiB',
allowedFileTypes: '允许类型 allowed_file_types',
allowedFileTypesHint: '逗号分隔:扩展名(jpg)或 MIMEimage/*),* 不限制',
sectionUploadRate: '上传频率限制',
uploadCount: '窗口内允许上传次数 uploadCount',
uploadCountHint: '最小 1,最大 {max}',
uploadMinute: '频率窗口(分钟)uploadMinute',
uploadMinuteHint: '最小 1,最大 {max}',
// —— v3:单位友好化 + 存储引擎 ——
unitHour: '小时',
unitDay: '天',
unitMB: 'MB',
unitGB: 'GB',
maxSaveTime: '最长保存时间 max_save_seconds',
maxSaveTimeHint: '0 = 不限制(服务端默认 7 天兜底),最大 365 天',
saveTimeUnlimited: '不限制(0',
maxFileSizeFriendly: '单文件上限 max_file_size',
maxFileSizeHintV3: '0 = 回落 uploadSize(当前 {fallback}),最大 10 GB',
sizeUnlimited: '不限制(0',
sectionEngine: '存储引擎',
engineCurrent: '当前引擎',
engineLocal: '本地存储',
engineWebdav: 'WebDAV',
engineS3: 'S3 对象存储',
engineSwitch: '切换到该引擎',
engineSwitching: '切换中…',
engineSwitchOk: '存储引擎已切换为 {engine}',
engineSwitchFail: '切换失败(已保持原引擎)',
engineParamsTitle: '引擎参数',
engineParamsSaved: '引擎参数已保存',
localRoot: '存储根目录 local_storage_path',
localRootHint: '留空 = 系统默认数据目录;修改后对新写入生效',
webdavUrl: '服务地址 webdav_url',
webdavUrlHint: '如 https://dav.example.com/dav/',
webdavRoot: '远端根目录 webdav_root_path',
webdavRootHint: '远端起始目录(不存在会自动逐级创建)',
webdavUser: '用户名 webdav_username',
webdavPass: '密码 webdav_password',
secretKeepHint: '留空或 ****** = 不修改',
s3Endpoint: '端点 s3_endpoint_url',
s3EndpointHint: '如 https://s3.example.com:9000AWS 官方可留空)',
s3Bucket: '存储桶 s3_bucket_name',
s3Region: '区域 s3_region_name',
s3Ak: 'AccessKeyID s3_access_key_id',
s3Sk: 'SecretAccessKey s3_secret_access_key',
s3Token: '会话令牌 aws_session_token(可选)',
s3Style: '寻址样式 s3_addressing_style',
styleAuto: 'auto(自动)',
stylePath: 'path(路径式,MinIO 常用)',
styleVirtual: 'virtual(虚拟主机式)',
engineParamsSave: '保存引擎参数',
approxSize: '≈ {size}',
},
},
}
+16
View File
@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { i18n } from './i18n'
import { installMock } from './mock'
import './styles/main.css'
// VITE_MOCK=1 时启用本地 mock(无后端推进前端开发)
installMock()
const app = createApp(App)
app.use(createPinia())
app.use(i18n)
app.use(router)
app.mount('#app')
+256
View File
@@ -0,0 +1,256 @@
/**
* 开发期 MockVITE_MOCK=1 时启用):在 service worker 之前仅用于本地调试,
* 通过拦截 fetch/XHR 实现无后端推进前端。
*/
import type { MockRoute } from './types'
const ok = (data: unknown, msg = '') => ({ code: 200, msg, data })
const err = (code: number, msg: string) => ({ code, msg, data: null })
let seq = 1
function textToCode(text: string): string {
let h = 0
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0
return (h % 100000).toString().padStart(5, '0')
}
const shares: { code: string; kind: 'text' | 'file'; name: string; size: number; text?: string }[] = []
const routes: MockRoute[] = [
{ method: 'GET', pattern: /\/api\/v1\/health$/, reply: () => ok({ status: 'ok', version: 'mock', storage: 'local', cache: 'memory' }) },
{
method: 'GET',
pattern: /\/api\/v1\/config$/,
reply: () =>
ok({
config: {
name: '文件快传 (Mock)',
site_domain: '',
description: '开发 Mock 模式 · 开箱即用的文件快传系统',
explain: '',
uploadSize: 10 * 1024 * 1024,
allowedFileTypes: [],
expireStyle: ['day', 'hour', 'minute', 'forever', 'count'],
enableChunk: true,
openUpload: true,
notify_enabled: 1,
notify_title: '系统通知',
notify_content: '欢迎使用文件快传(Mock)。',
logo_url: '',
favicon_url: '',
background_url: '',
footer_text: '',
footer_beian: '',
max_file_size: 0,
max_save_seconds: 0,
max_save_count: 0,
uploadCount: 10,
uploadMinute: 1,
},
meta: { version: 'mock', features: { chunkUpload: true, guestUpload: true } },
}),
},
{
method: 'POST',
pattern: /\/share\/text$/,
reply: (_m, body) => {
const text = String((body as Record<string, string>)?.text ?? '')
const code = textToCode(text + seq++)
shares.push({ code, kind: 'text', name: 'Text.txt', size: new Blob([text]).size, text })
return ok({ code })
},
},
{
method: 'POST',
pattern: /\/share\/file$/,
reply: (_m, body) => {
const fd = body as FormData
const file = fd?.get?.('file')
if (!(file instanceof File)) return err(400, '缺少文件')
const code = textToCode(file.name + seq++)
shares.push({ code, kind: 'file', name: file.name, size: file.size })
return ok({ code, name: file.name })
},
},
{
method: 'GET',
pattern: /\/share\/metadata$/,
reply: (_m, _body, q) => {
const s = shares.find((x) => x.code === q?.get('code'))
if (!s) return err(404, '文件不存在')
return ok({
code: s.code,
name: s.name,
size: s.size,
type: s.kind,
is_text: s.kind === 'text',
created_at: new Date().toISOString(),
expired_at: null,
expired_count: -1,
used_count: 0,
remaining_downloads: null,
})
},
},
{
method: 'GET',
pattern: /\/share\/select$/,
reply: (_m, _body, q) => {
const s = shares.find((x) => x.code === q?.get('code'))
if (!s) return { __rawError: 404 }
if (s.kind === 'text') return { __rawText: s.text ?? '' }
return { __rawBlob: new Blob([`mock-content-of-${s.name}`], { type: 'application/octet-stream' }), __rawName: s.name }
},
},
{
method: 'POST',
pattern: /\/chunk\/upload\/init$/,
reply: (_m, body) => {
const b = body as Record<string, unknown>
const total = Math.ceil(Number(b?.file_size ?? 0) / Number(b?.chunk_size ?? 1024))
return ok({ existed: false, upload_id: `mock-${seq++}`, chunk_size: b?.chunk_size, total_chunks: total, uploaded_chunks: [] })
},
},
{ method: 'POST', pattern: /\/chunk\/upload\/[^/]+\/\d+$/, reply: () => ok({ chunk_hash: 'mockhash' }) },
{
method: 'GET',
pattern: /\/chunk\/upload\/status\/[^/]+$/,
reply: (m) => {
const id = String(m?.[0]?.split('/').pop() ?? 'mock')
return ok({ upload_id: id, chunk_size: 1024, total_chunks: 1, uploaded_chunks: [0] })
},
},
{
method: 'POST',
pattern: /\/chunk\/upload\/complete\/[^/]+$/,
reply: () => {
const code = textToCode('chunk' + seq++)
shares.push({ code, kind: 'file', name: 'chunked.bin', size: 1024 })
return ok({ code, name: 'chunked.bin' })
},
},
{ method: 'DELETE', pattern: /\/chunk\/upload\/[^/]+$/, reply: () => ok({ message: '已取消' }) },
{ method: 'POST', pattern: /\/admin\/login$/, reply: (_m, body) => ((body as { password?: string })?.password === 'admin123' ? ok({ id: 'admin', username: 'admin', token: 'mock-token', token_type: 'Bearer', expires_in: 86400 }) : err(401, '密码错误')) },
{ method: 'GET', pattern: /\/admin\/verify$/, reply: (_m, _b, _q) => (getToken() === 'mock-token' ? ok({ is_admin: true }) : err(401, '未登录')) },
{ method: 'POST', pattern: /\/admin\/logout$/, reply: () => ok({ ok: true }) },
{
method: 'GET',
pattern: /\/admin\/file\/list$/,
reply: () =>
ok({
page: 1,
size: 10,
total: shares.length,
data: shares.map((s, i) => ({
id: i + 1,
code: s.code,
name: s.name,
suffix: s.name.includes('.') ? s.name.split('.').pop() : '',
size: s.size,
isText: s.kind === 'text',
expiredAt: null,
expiredCount: -1,
usedCount: 0,
createdAt: new Date().toISOString(),
isExpired: false,
})),
}),
},
{ method: 'DELETE', pattern: /\/admin\/file\/delete$/, reply: () => ok(null) },
{ method: 'POST', pattern: /\/admin\/file\/batch-delete$/, reply: () => ok(null) },
{ method: 'PATCH', pattern: /\/admin\/file\/update$/, reply: () => ok('更新成功') },
{
method: 'GET',
pattern: /\/admin\/config\/get$/,
reply: () =>
ok({
site_name: '文件快传 (Mock)',
logo_url: '',
favicon_url: '',
background_url: '',
footer_text: '',
footer_beian: '',
notify_enabled: 1,
notify_title: '系统通知',
notify_content: '欢迎使用文件快传(Mock)。',
max_save_seconds: 0,
max_save_count: 0,
max_file_size: 0,
allowed_file_types: ['*'],
uploadCount: 10,
uploadMinute: 1,
openUpload: 1,
}),
},
{ method: 'PATCH', pattern: /\/admin\/config\/update$/, reply: () => ok(null) },
{
method: 'GET',
pattern: /\/admin\/audit\/list$/,
reply: () =>
ok({
page: 1,
size: 20,
total: 2,
data: [
{ id: 1, action: 'upload', result: 'success', file_code: shares[0]?.code ?? '10001', file_name: 'demo.txt', size_bytes: 1024, transferred_bytes: 1024, ip: '127.0.0.1', user_agent: 'Mozilla/5.0 (Mock)', device_os: 'macOS', device_browser: 'Chrome', device_type: 'desktop', actor: 'guest', error_msg: '', duration_ms: 42, created_at: new Date().toISOString() },
{ id: 2, action: 'download', result: 'success', file_code: shares[0]?.code ?? '10001', file_name: 'demo.txt', size_bytes: 1024, transferred_bytes: 1024, ip: '192.168.1.2', user_agent: 'Mozilla/5.0 (iPhone)', device_os: 'iOS', device_browser: 'Safari', device_type: 'mobile', actor: 'guest', error_msg: '', duration_ms: 88, created_at: new Date().toISOString() },
],
}),
},
]
function getToken(): string {
try {
return localStorage.getItem('fcb_admin_token') ?? ''
} catch {
return ''
}
}
/** 安装 fetch 拦截(dev-only,不覆盖 XHR —— mock 下上传走 XHR 也会失败,可接受) */
export function installMock(): void {
if (import.meta.env.VITE_MOCK !== '1') return
const origFetch = globalThis.fetch.bind(globalThis)
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
const method = (init?.method ?? 'GET').toUpperCase()
const qIndex = url.indexOf('?')
const path = qIndex >= 0 ? url.slice(0, qIndex) : url
const query = qIndex >= 0 ? new URL(url, location.origin).searchParams : undefined
for (const r of routes) {
if (r.method !== method) continue
const m = r.pattern.exec(path)
if (!m) continue
let body: unknown = undefined
if (init?.body) {
if (typeof init.body === 'string') {
try {
body = JSON.parse(init.body)
} catch {
body = Object.fromEntries(new URLSearchParams(init.body))
}
} else if (init.body instanceof FormData) {
body = init.body
}
}
const res = r.reply(m, body, query)
await new Promise((res) => setTimeout(res, 120)) // 模拟网络延迟
if (res && typeof res === 'object' && '__rawText' in (res as Record<string, unknown>)) {
return new Response((res as { __rawText: string }).__rawText, { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8' } })
}
if (res && typeof res === 'object' && '__rawBlob' in (res as Record<string, unknown>)) {
const rb = res as { __rawBlob: Blob; __rawName: string }
return new Response(rb.__rawBlob, { status: 200, headers: { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(rb.__rawName)}` } })
}
if (res && typeof res === 'object' && '__rawError' in (res as Record<string, unknown>)) {
const e = err((res as { __rawError: number }).__rawError, '文件不存在')
return new Response(JSON.stringify(e), { status: 200, headers: { 'content-type': 'application/json' } })
}
return new Response(JSON.stringify(res), { status: 200, headers: { 'content-type': 'application/json' } })
}
return origFetch(input, init)
}
console.info('[mock] 文件快传 API mock enabled (VITE_MOCK=1)')
}
+13
View File
@@ -0,0 +1,13 @@
export interface MockRoute {
method: string
pattern: RegExp
reply: (
m: RegExpMatchArray | null,
body: unknown,
query?: URLSearchParams,
) =>
| { code: number; msg: string; data: unknown }
| { __rawText: string }
| { __rawBlob: Blob; __rawName: string }
| { __rawError: number }
}
+51
View File
@@ -0,0 +1,51 @@
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import { setUnauthorizedHandler } from '@/api/http'
// 默认 history 模式(go-api NoRoute 已实现 SPA 回退:静态资源优先、GET+text/html 回 index.html、
// /api/* JSON 404);如后端未做回退可设 VITE_ROUTER_MODE=hash 切换。
const mode = (import.meta.env.VITE_ROUTER_MODE ?? 'history') === 'hash' ? createWebHashHistory() : createWebHistory()
const router = createRouter({
history: mode,
routes: [
// 需求⑩:标题键走 i18ndocument.title 由 App.vue 统一组装:t(titleKey) · 站点名)
{ path: '/', name: 'home', component: () => import('@/views/HomeView.vue'), meta: { titleKey: 'nav.home' } },
{ path: '/s/:code', name: 'pickup', component: () => import('@/views/PickupView.vue'), meta: { titleKey: 'nav.home' } },
{ path: '/admin/login', name: 'admin-login', component: () => import('@/views/admin/LoginView.vue'), meta: { titleKey: 'admin.nav.title' } },
{
path: '/admin',
component: () => import('@/views/admin/AdminLayout.vue'),
meta: { requiresAuth: true, titleKey: 'admin.nav.title' },
children: [
{ path: '', redirect: { name: 'admin-files' } },
{ path: 'files', name: 'admin-files', component: () => import('@/views/admin/FilesView.vue'), meta: { titleKey: 'admin.nav.files' } },
{ path: 'audit', name: 'admin-audit', component: () => import('@/views/admin/AuditView.vue'), meta: { titleKey: 'admin.nav.audit' } },
{ path: 'settings', name: 'admin-settings', component: () => import('@/views/admin/SettingsView.vue'), meta: { titleKey: 'admin.nav.settings' } },
],
},
{ path: '/docs', name: 'docs', component: () => import('@/views/DocsView.vue'), meta: { titleKey: 'nav.docs' } },
{ path: '/docs/:slug', name: 'docs-detail', component: () => import('@/views/DocsView.vue'), meta: { titleKey: 'nav.docs' } },
{ path: '/openapi', name: 'openapi', component: () => import('@/views/OpenApiView.vue'), meta: { titleKey: 'nav.openapi' } },
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFoundView.vue'), meta: { titleKey: 'notFound.title' } },
],
scrollBehavior(to, _from, saved) {
if (saved) return saved
if (to.hash) return { el: to.hash, behavior: 'smooth' }
return { top: 0 }
},
})
router.beforeEach((to) => {
if (to.meta.requiresAuth && !localStorage.getItem('fcb_admin_token')) {
return { name: 'admin-login', query: { redirect: to.fullPath } }
}
})
// API 401 → 跳转管理登录(携带回跳地址)
setUnauthorizedHandler(() => {
const current = router.currentRoute.value
if (current.name === 'admin-login') return
void router.push({ name: 'admin-login', query: { redirect: current.fullPath } })
})
export default router
+61
View File
@@ -0,0 +1,61 @@
/** 管理员会话 store:JWT 持久化 + 登录/登出/校验 */
import { defineStore } from 'pinia'
import { adminLogin, adminLogout, adminVerify } from '@/api/admin'
import { getToken, setToken } from '@/api/http'
const ADMIN_FLAG_KEY = 'fcb_admin_flag'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: getToken(),
checked: false,
}),
getters: {
isAuthed: (s) => Boolean(s.token),
},
actions: {
async login(password: string): Promise<void> {
const res = await adminLogin(password)
const token = res?.token
if (!token) throw new Error('登录响应缺少 token')
this.token = token
setToken(token)
try {
localStorage.setItem(ADMIN_FLAG_KEY, '1')
} catch {
/* ignore */
}
this.checked = true
},
/** 校验现有 token 是否仍有效(进入管理页时调用) */
async verify(): Promise<boolean> {
if (!this.token) return false
try {
await adminVerify()
this.checked = true
return true
} catch {
this.reset()
return false
}
},
async logout(): Promise<void> {
try {
if (this.token) await adminLogout()
} catch {
/* 后端登出失败不阻塞本地清理 */
}
this.reset()
},
reset(): void {
this.token = ''
this.checked = false
setToken('')
try {
localStorage.removeItem(ADMIN_FLAG_KEY)
} catch {
/* ignore */
}
},
},
})
+161
View File
@@ -0,0 +1,161 @@
/**
* 站点配置 store:公共配置 + Logo/favicon(需求④)+ 背景/页脚/通知(⑤⑥⑦)+ 策略范围(⑧)。
* 远程 URL 默认值已删除:Logo/favicon 使用本地打包资源兜底(config 自定义仍优先)。
*/
import { defineStore } from 'pinia'
import { request } from '@/api/http'
import { paths } from '@/api/paths'
import { pick } from '@/utils/format'
import logoLocal from '@/assets/brand/logo.svg'
import faviconLocal from '@/assets/brand/favicon.png'
/** 需求④:默认 Logo(导航栏)与 favicon(浏览器标签)——本地打包资源,无远程依赖 */
export const DEFAULT_LOGO_URL = logoLocal
export const DEFAULT_FAVICON_URL = faviconLocal
export const DEFAULT_SITE_NAME = '文件快传'
export interface ConfigState {
loaded: boolean
loading: boolean
siteName: string
siteDomain: string
description: string
explain: string
uploadSize: number
allowedFileTypes: string[]
expireStyle: string[]
enableChunk: boolean
openUpload: boolean
notifyEnabled: boolean
notifyTitle: string
notifyContent: string
logoUrl: string
faviconUrl: string
backgroundUrl: string
footerText: string
footerBeian: string
maxFileSize: number
maxSaveSeconds: number
maxSaveCount: number
uploadCount: number
uploadMinute: number
}
function toBool(v: unknown, fallback = true): boolean {
if (v === undefined || v === null) return fallback
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v !== 0
return String(v) !== '0' && String(v) !== 'false' && String(v) !== ''
}
function toList(v: unknown): string[] {
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean)
if (typeof v === 'string') {
return v
.split(',')
.map((x) => x.trim())
.filter(Boolean)
}
return []
}
function toNum(v: unknown, fallback: number): number {
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
export const useConfigStore = defineStore('config', {
state: (): ConfigState => ({
loaded: false,
loading: false,
siteName: DEFAULT_SITE_NAME,
siteDomain: '',
description: '',
explain: '',
uploadSize: 10 * 1024 * 1024,
allowedFileTypes: [],
expireStyle: ['day', 'hour', 'minute', 'forever', 'count'],
enableChunk: false,
openUpload: true,
notifyEnabled: false,
notifyTitle: '',
notifyContent: '',
logoUrl: '',
faviconUrl: '',
backgroundUrl: '',
footerText: '',
footerBeian: '',
maxFileSize: 0,
maxSaveSeconds: 0,
maxSaveCount: 0,
uploadCount: 0,
uploadMinute: 0,
}),
getters: {
/** 需求④:自定义优先,本地打包资源兜底 */
displayLogoUrl: (s) => (s.logoUrl?.trim() ? s.logoUrl : DEFAULT_LOGO_URL),
displayFaviconUrl: (s) => (s.faviconUrl?.trim() ? s.faviconUrl : DEFAULT_FAVICON_URL),
displayName: (s) => (s.siteName?.trim() ? s.siteName : DEFAULT_SITE_NAME),
/** v3.1:分享链接基础地址——配置了对外域名用之,否则当前访问地址 */
shareLinkBase: (s): string => (s.siteDomain?.trim() ? s.siteDomain.trim().replace(/\/$/, '') : location.origin),
/** ⑧ 存储策略:max_file_size>0 时优先,否则回落 uploadSize */
effectiveMaxFileSize(): number {
return this.maxFileSize > 0 ? this.maxFileSize : this.uploadSize
},
},
actions: {
/** 拉取公共配置;失败静默使用默认值(未部署后端时前端仍可浏览) */
async load(): Promise<void> {
this.loading = true
try {
const raw = await request<Record<string, unknown>>(paths.publicConfig, { timeout: 8000 })
const cfg = (pick<Record<string, unknown>>(raw, ['config']) ?? raw) as Record<string, unknown>
this.siteName = String(pick<string>(cfg, ['name', 'site_name', 'siteName']) ?? DEFAULT_SITE_NAME)
// v3.1:站点对外域名(空=当前访问地址)
this.siteDomain = String(pick<string>(cfg, ['site_domain', 'siteDomain']) ?? '').trim()
this.description = String(pick<string>(cfg, ['description']) ?? '')
this.explain = String(pick<string>(cfg, ['explain', 'page_explain']) ?? '')
this.uploadSize = toNum(pick<unknown>(cfg, ['uploadSize', 'upload_size']), 10 * 1024 * 1024)
this.allowedFileTypes = toList(pick<unknown>(cfg, ['allowedFileTypes', 'allowed_file_types']))
const styles = toList(pick<unknown>(cfg, ['expireStyle', 'expire_style']))
if (styles.length) this.expireStyle = styles
this.enableChunk = toBool(pick<unknown>(cfg, ['enableChunk', 'enable_chunk']), false)
this.openUpload = toBool(pick<unknown>(cfg, ['openUpload', 'open_upload']), true)
this.notifyTitle = String(pick<string>(cfg, ['notify_title', 'notifyTitle']) ?? '')
this.notifyContent = String(pick<string>(cfg, ['notify_content', 'notifyContent']) ?? '')
// 需求⑦:通知开关(1/0
this.notifyEnabled = toBool(pick<unknown>(cfg, ['notify_enabled', 'notifyEnabled']), false)
// 需求⑤:背景图
this.backgroundUrl = String(pick<string>(cfg, ['background_url', 'backgroundUrl']) ?? '').trim()
// 需求⑥:页脚
this.footerText = String(pick<string>(cfg, ['footer_text', 'footerText']) ?? '')
this.footerBeian = String(pick<string>(cfg, ['footer_beian', 'footerBeian']) ?? '')
// 需求⑧:保存策略与上传频率
this.maxFileSize = toNum(pick<unknown>(cfg, ['max_file_size', 'maxFileSize', 'maxFileSize']), 0)
this.maxSaveSeconds = toNum(pick<unknown>(cfg, ['max_save_seconds', 'maxSaveSeconds']), 0)
this.maxSaveCount = toNum(pick<unknown>(cfg, ['max_save_count', 'maxSaveCount']), 0)
this.uploadCount = toNum(pick<unknown>(cfg, ['uploadCount', 'upload_count']), 0)
this.uploadMinute = toNum(pick<unknown>(cfg, ['uploadMinute', 'upload_minute']), 0)
// 需求④:运行时优先读 config 的 logo_url/favicon_url,空值回退本地默认
this.logoUrl = String(pick<string>(cfg, ['logo_url', 'logoUrl']) ?? '').trim()
this.faviconUrl = String(pick<string>(cfg, ['favicon_url', 'faviconUrl']) ?? '').trim()
this.loaded = true
this.applyToDocument()
} catch {
// 保持默认值
} finally {
this.loading = false
}
},
/** 将站点名与 favicon 应用到 document(管理端保存配置后调用即可全站生效) */
applyToDocument(): void {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = this.displayFaviconUrl
},
},
})
+38
View File
@@ -0,0 +1,38 @@
/** 轻量 Toast 通知 */
import { defineStore } from 'pinia'
export type ToastType = 'success' | 'error' | 'info'
export interface ToastItem {
id: number
type: ToastType
text: string
}
let seq = 0
export const useToastStore = defineStore('toast', {
state: () => ({
items: [] as ToastItem[],
}),
actions: {
push(text: string, type: ToastType = 'info', duration = 3200): void {
const id = ++seq
this.items.push({ id, type, text })
if (this.items.length > 4) this.items.shift()
setTimeout(() => this.dismiss(id), duration)
},
success(text: string): void {
this.push(text, 'success')
},
error(text: string): void {
this.push(text, 'error', 4200)
},
info(text: string): void {
this.push(text, 'info')
},
dismiss(id: number): void {
this.items = this.items.filter((t) => t.id !== id)
},
},
})
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
/**
* 主题系统(需求②):
* - 三态:light / dark / system(跟随 prefers-color-scheme
* - 默认跟随系统;localStorage 记忆(键 fcb_theme_mode
* - 实际生效主题写入 <html data-theme="light|dark">CSS 变量按此切换
* - Naive UI darkTheme 由 App.vue 读取 useTheme().resolved 响应式传递
*/
import { ref, watch } from 'vue'
export type ThemeMode = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
export const THEME_MODES: ThemeMode[] = ['light', 'dark', 'system']
const STORAGE_KEY = 'fcb_theme_mode'
function readStoredMode(): ThemeMode | null {
try {
const v = localStorage.getItem(STORAGE_KEY)
return v === 'light' || v === 'dark' || v === 'system' ? v : null
} catch {
return null
}
}
function persist(mode: ThemeMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode)
} catch {
/* ignore */
}
}
function systemPrefersDark(): boolean {
return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches
}
/** 当前模式(未初始化时按 localStorage → 默认 system */
const mode = ref<ThemeMode>(readStoredMode() ?? 'system')
/** 实际生效主题(响应式,供 Naive UI 与 <html data-theme> 使用) */
const resolved = ref<ResolvedTheme>(mode.value === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : mode.value)
let started = false
/** 初始化系统主题监听(App.vue 挂载时调用一次) */
function start(): void {
if (started || typeof matchMedia !== 'function') return
started = true
const mq = matchMedia('(prefers-color-scheme: dark)')
mq.addEventListener?.('change', () => {
if (mode.value === 'system') resolved.value = mq.matches ? 'dark' : 'light'
})
}
function apply(): void {
start()
resolved.value = mode.value === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : mode.value
document.documentElement.dataset.theme = resolved.value
}
watch(mode, apply, { immediate: true })
/** 切换模式并记忆(需求②:导航栏三态切换 + localStorage */
export function setThemeMode(next: ThemeMode): void {
mode.value = next
persist(next)
}
export function useTheme() {
return {
mode,
resolved,
setMode: setThemeMode,
}
}
+122
View File
@@ -0,0 +1,122 @@
/** 分片大小与并发策略:按文件体积自适应,大文件用大分片 + 多并发。 */
import { sha256Hex } from './format'
export interface ChunkPlan {
chunkSize: number
concurrency: number
}
export function planChunks(fileSize: number): ChunkPlan {
if (fileSize <= 10 * 1024 * 1024) return { chunkSize: 1 * 1024 * 1024, concurrency: 3 } // ≤10MB → 1MB
if (fileSize <= 100 * 1024 * 1024) return { chunkSize: 5 * 1024 * 1024, concurrency: 3 } // ≤100MB → 5MB
if (fileSize <= 512 * 1024 * 1024) return { chunkSize: 10 * 1024 * 1024, concurrency: 2 }
return { chunkSize: 20 * 1024 * 1024, concurrency: 2 }
}
export interface ChunkDeps {
init: (fileName: string, fileSize: number, chunkSize: number, fileHash: string) => Promise<{
upload_id: string
chunk_size: number
total_chunks: number
uploaded_chunks: number[]
}>
uploadOne: (uploadId: string, index: number, blob: Blob) => Promise<void>
status: (uploadId: string) => Promise<{ uploaded_chunks: number[]; total_chunks: number }>
finish: (uploadId: string, expireValue: number, expireStyle: string, code?: string) => Promise<{ code: string; name: string }>
cancel: (uploadId: string) => Promise<void>
}
export interface ChunkUploadOptions {
file: File
expireValue: number
expireStyle: string
customCode?: string // v3.1:自定义提取码(4-8 位字母数字)
onProgress?: (percent: number, uploadedBytes: number, totalBytes: number) => void
}
/** 返回取消句柄:调用返回值上的 cancel() 中止上传并清理服务端分片。 */
export interface ChunkUploadSession {
promise: Promise<{ code: string; name: string }>
cancel: () => void
}
/**
* 分片上传:断点续传(file_hash 匹配已传分片)、单分片失败自动重试一次、
* 并发任务池、完成前向服务端核对分片清单。
*/
export function uploadFileInChunks(opts: ChunkUploadOptions, deps: ChunkDeps): ChunkUploadSession {
const { file, expireValue, expireStyle, customCode, onProgress } = opts
let cancelled = false
const promise = (async (): Promise<{ code: string; name: string }> => {
const plan = planChunks(file.size)
const totalChunks = Math.max(1, Math.ceil(file.size / plan.chunkSize))
// 整文件 SHA-256 作为断点续传 key(对齐参考实现语义;计算失败降级为指纹串)
let fileHash: string
try {
fileHash = await sha256Hex(await file.arrayBuffer())
} catch {
fileHash = `nofp-${file.size}-${file.lastModified}`
}
const initRes = await deps.init(file.name, file.size, plan.chunkSize, fileHash)
const uploadId = initRes.upload_id
const effChunkSize = initRes.chunk_size || plan.chunkSize
const effTotal = initRes.total_chunks || totalChunks
const done = new Set<number>(initRes.uploaded_chunks ?? [])
const report = () => {
if (!onProgress) return
let uploadedBytes = 0
for (const i of done) {
const start = i * effChunkSize
uploadedBytes += Math.max(0, Math.min(effChunkSize, file.size - start))
}
onProgress(Math.min(100, Math.round((uploadedBytes / file.size) * 100)), uploadedBytes, file.size)
}
report()
const uploadOneWithRetry = async (index: number): Promise<void> => {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const start = index * effChunkSize
const blob = file.slice(start, Math.min(start + effChunkSize, file.size))
await deps.uploadOne(uploadId, index, blob)
done.add(index)
report()
return
} catch (e) {
if (attempt === 1) throw e
}
}
}
// 受并发上限的简易任务池
let nextIndex = 0
const worker = async (): Promise<void> => {
while (!cancelled) {
const index = nextIndex++
if (index >= effTotal) return
if (done.has(index)) continue
await uploadOneWithRetry(index)
}
}
await Promise.all(Array.from({ length: plan.concurrency }, () => worker()))
if (cancelled) {
await deps.cancel(uploadId).catch(() => undefined)
throw new DOMException('上传已取消', 'AbortError')
}
// 完成前向服务端核对一次分片清单(防个别分片响应丢失)
if (done.size < effTotal) {
const st = await deps.status(uploadId)
for (const i of st.uploaded_chunks ?? []) done.add(i)
}
return deps.finish(uploadId, expireValue, expireStyle, customCode)
})()
return { promise, cancel: () => (cancelled = true) }
}
+141
View File
@@ -0,0 +1,141 @@
/** 前端工具函数 */
import { i18n } from '@/i18n'
function tt(key: string, params?: Record<string, unknown>): string {
return i18n.global.t(key, params ?? {})
}
export function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return '-'
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let v = bytes
let i = -1
do {
v /= 1024
i++
} while (v >= 1024 && i < units.length - 1)
return `${v.toFixed(v >= 100 ? 0 : 1)} ${units[i]}`
}
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '-'
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso)
const p = (n: number) => `${n}`.padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
/** 剩余有效期的人类描述 */
export function formatRemaining(expiredAt: string | null | undefined): string {
if (!expiredAt) return tt('time.forever')
const t = new Date(expiredAt).getTime()
if (Number.isNaN(t)) return tt('time.forever')
const diff = t - Date.now()
if (diff <= 0) return tt('time.expired')
const m = Math.floor(diff / 60_000)
if (m < 1) return tt('time.lessThanMinute')
if (m < 60) return tt('time.minutes', { n: m })
const h = Math.floor(m / 60)
if (h < 24) return tt('time.hoursMinutes', { h, m: m % 60 })
const d = Math.floor(h / 24)
return tt('time.daysHours', { d, h: h % 24 })
}
export function formatDuration(ms: number | null | undefined): string {
if (ms === null || ms === undefined) return '-'
if (ms < 1000) return `${ms} ms`
return `${(ms / 1000).toFixed(2)} s`
}
/** 过期样式选项(对齐参考实现的 expireStyle 配置;label 用 i18n 键名,展示时经 expireStyleLabel 翻译) */
export const EXPIRE_STYLES: { value: string; label: string }[] = [
{ value: 'day', label: 'day' },
{ value: 'hour', label: 'hour' },
{ value: 'minute', label: 'minute' },
{ value: 'count', label: 'count' },
{ value: 'forever', label: 'forever' },
]
/** 过期样式翻译(未知样式原样返回) */
export function expireStyleLabel(style: string): string {
const known = EXPIRE_STYLES.find((s) => s.value === style)
if (known) return tt(`expireStyle.${known.value}`)
return style
}
/** 简单防抖 */
export function debounce<T extends (...args: never[]) => void>(fn: T, wait: number): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
return (...args: Parameters<T>) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), wait)
}
}
/** 从 Content-Disposition 提取文件名(支持 RFC 5987 filename*=UTF-8'' */
export function filenameFromDisposition(header: string | null): string | null {
if (!header) return null
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header)
if (star) {
try {
return decodeURIComponent(star[1].replace(/["']/g, '').trim())
} catch {
/* fallthrough */
}
}
const plain = /filename="?([^";]+)"?/i.exec(header)
return plain ? plain[1] : null
}
/** 触发浏览器下载 Blob */
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 5_000)
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// 兼容非安全上下文(http 部署)
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
ta.remove()
return ok
} catch {
return false
}
}
}
/** 计算 SHA-256hex),用于分片上传 file_hash */
export async function sha256Hex(data: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/** 宽松取值:对象上第一个存在的键(用于兼容 camelCase/snake_case 响应) */
export function pick<T = unknown>(obj: Record<string, unknown>, keys: string[]): T | undefined {
for (const k of keys) {
if (obj && typeof obj === 'object' && k in obj && obj[k] !== undefined && obj[k] !== null) {
return obj[k] as T
}
}
return undefined
}
+98
View File
@@ -0,0 +1,98 @@
/** Markdown 渲染:marked 解析 + DOM 级净化(防 XSS)+ 标题锚点 + 目录提取 */
import { marked } from 'marked'
export interface TocItem {
id: string
text: string
level: number
}
export interface RenderResult {
html: string
toc: TocItem[]
}
function slugify(text: string, used: Set<string>): string {
const base =
text
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.trim()
.replace(/\s+/g, '-') || 'section'
let slug = base
let i = 2
while (used.has(slug)) slug = `${base}-${i++}`
used.add(slug)
return slug
}
// L7 加固:补齐 foreign-content/嵌入类标签与危险属性清单(原仅黑名单 9 类 +
// on* + javascript:,未覆盖 srcdoc/formaction/xlink:href/srcset/data: URL 等)
const BLOCKED_TAGS = new Set([
'script', 'style', 'iframe', 'object', 'embed', 'form', 'link', 'meta', 'base',
'svg', 'math', 'frame', 'frameset', 'applet', 'template', 'noscript', 'title',
])
/** 危险属性:事件类、URL 注入类与沙箱逃逸类 */
const BLOCKED_ATTRS = new Set([
'srcdoc', 'sandbox', 'formaction', 'action', 'xlink:href', 'srcset', 'poster', 'background', 'dynsrc', 'lowsrc', 'data',
])
/** 仅允许 http(s)、站内相对路径、锚点与 mailtosrc 另允许 data:image/ */
function safeUrl(value: string, isSrc: boolean): boolean {
const v = value.trim()
if (isSrc && /^data:image\//i.test(v)) return true
return /^(https?:|mailto:|\/|#|\.\/)/i.test(v) || !/^[a-z][a-z0-9+.-]*:/i.test(v)
}
/** 净化 HTML:移除危险标签/危险属性/非白名单协议 URL */
export function sanitizeHtml(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html')
for (const el of [...doc.body.querySelectorAll('*')]) {
const tag = el.tagName.toLowerCase()
if (BLOCKED_TAGS.has(tag)) {
el.remove()
continue
}
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase()
if (name.startsWith('on') || BLOCKED_ATTRS.has(name)) {
el.removeAttribute(attr.name)
continue
}
if ((name === 'href' || name === 'src' || name.endsWith(':src') || name.endsWith(':href')) &&
!safeUrl(attr.value, name === 'src')) {
el.removeAttribute(attr.name)
}
}
}
return doc.body.innerHTML
}
/** 渲染 markdown → 净化后的 HTML + 标题目录(h2/h3 作为二级/三级目录) */
export function renderMarkdown(md: string): RenderResult {
const rawHtml = marked.parse(md, { gfm: true, breaks: false }) as string
const doc = new DOMParser().parseFromString(rawHtml, 'text/html')
// 外链新窗口打开;站内锚点保持
for (const a of [...doc.body.querySelectorAll('a[href]')]) {
const href = a.getAttribute('href') ?? ''
if (/^https?:\/\//i.test(href)) {
a.setAttribute('target', '_blank')
a.setAttribute('rel', 'noopener noreferrer')
}
}
const used = new Set<string>()
const toc: TocItem[] = []
for (const h of [...doc.body.querySelectorAll('h1, h2, h3')]) {
const level = Number(h.tagName.substring(1))
const text = (h.textContent ?? '').trim()
if (!text) continue
const id = slugify(text, used)
h.setAttribute('id', id)
if (level >= 2) toc.push({ id, text, level })
}
return { html: sanitizeHtml(doc.body.innerHTML), toc }
}
+147
View File
@@ -0,0 +1,147 @@
<script setup lang="ts">
/** 站内 /docs 文档页:侧边栏章节(docs/api/*.md 一级标题)+ 全文检索 + 目录 */
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import PageShell from '@/components/PageShell.vue'
import { docs, discoverRemoteDocs, loadDoc, type DocEntry } from '@/docs/docsSource'
import { renderMarkdown, type TocItem } from '@/utils/markdown'
import { useToastStore } from '@/stores/toast'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToastStore()
const allDocs = ref<DocEntry[]>([...docs])
const activeSlug = ref('')
const content = ref('')
const renderedHtml = ref('')
const toc = ref<TocItem[]>([])
const loading = ref(false)
const search = ref('')
onMounted(async () => {
// 运行时补充远端文档(后端挂载 docs/api/index.json 时)
const remote = await discoverRemoteDocs()
if (remote.length) allDocs.value = [...docs, ...remote]
void syncFromRoute()
})
const slugFromRoute = (): string => {
if (typeof route.params.slug === 'string') return route.params.slug
const q = route.query.p
return typeof q === 'string' ? q : ''
}
async function syncFromRoute(): Promise<void> {
const slug = slugFromRoute()
const list = allDocs.value
if (!list.length) return
const target = list.find((d) => d.slug === slug) ?? list[0]
if (!target) return
if (target.slug === activeSlug.value && content.value) return
activeSlug.value = target.slug
loading.value = true
try {
content.value = await loadDoc(target)
const { html, toc: tocItems } = renderMarkdown(content.value)
renderedHtml.value = html
toc.value = tocItems
} catch {
renderedHtml.value = ''
toc.value = []
toast.error(t('docs.loadFailed', { title: target.title }))
} finally {
loading.value = false
}
}
watch(
() => route.fullPath,
() => void syncFromRoute(),
)
function selectDoc(slug: string): void {
void router.push({ name: 'docs-detail', params: { slug } })
}
function scrollToSection(id: string): void {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
}
/** 检索:标题 + 全文匹配(含匹配数提示) */
const filteredDocs = computed(() => {
const kw = search.value.trim().toLowerCase()
if (!kw) return allDocs.value.map((d) => ({ doc: d, hits: -1 }))
return allDocs.value
.map((d) => {
const text = d.embedded ?? ''
const count = (text.toLowerCase().split(kw).length - 1) + (d.title.toLowerCase().includes(kw) ? 1 : 0)
return { doc: d, hits: count }
})
.filter((x) => x.hits > 0)
.sort((a, b) => b.hits - a.hits)
})
const searching = computed(() => search.value.trim().length > 0)
</script>
<template>
<PageShell>
<div class="docs-shell">
<aside class="docs-sidebar">
<input v-model="search" class="input docs-search" :placeholder="t('docs.searchPlaceholder')" />
<div v-if="!allDocs.length" class="empty" style="padding: 20px 8px">
{{ t('docs.notGenerated') }}<br />
<span class="hint">{{ t('docs.buildHint') }}</span>
</div>
<template v-else>
<nav class="doc-list" :aria-label="t('docs.sidebar')">
<a
v-for="item in filteredDocs"
:key="item.doc.slug"
:class="{ active: item.doc.slug === activeSlug }"
href="#"
@click.prevent="selectDoc(item.doc.slug)"
>
{{ item.doc.title }}
<span v-if="searching && item.hits > 0" class="badge badge-muted" style="margin-left: 6px">
{{ item.hits }}
</span>
</a>
</nav>
<p v-if="searching && !filteredDocs.length" class="hint" style="padding: 0 11px">{{ t('docs.noMatch') }}</p>
<div v-if="toc.length && !searching" class="doc-toc">
<div class="toc-title">{{ t('docs.tocTitle') }}</div>
<a
v-for="t in toc"
:key="t.id"
:href="`#${t.id}`"
:class="{ 'lvl-3': t.level >= 3 }"
@click.prevent="scrollToSection(t.id)"
>
{{ t.text }}
</a>
</div>
</template>
</aside>
<article class="docs-content">
<div v-if="loading" class="loading-block"><span class="spin" aria-hidden="true"></span> {{ t('docs.loading') }}</div>
<div v-else-if="!allDocs.length" class="empty">
<div class="empty-icon">📚</div>
<p style="font-weight: 600; color: var(--c-text)">{{ t('docs.preparing') }}</p>
<p class="hint" style="max-width: 420px; margin: 0 auto">
{{ t('docs.preparingHint') }}
</p>
</div>
<div v-else-if="!renderedHtml" class="empty">{{ t('docs.emptyContent') }}</div>
<!-- 内容已在渲染前做 DOM 级净化去除 script/on*/javascript: -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-else class="markdown-body" v-html="renderedHtml"></div>
</article>
</div>
</PageShell>
</template>
+279
View File
@@ -0,0 +1,279 @@
<script setup lang="ts">
/** 首页:文本/文件分享 Tab + 快速取件(需求⑧:策略动态提示;需求⑩:i18n) */
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import PageShell from '@/components/PageShell.vue'
import ExpirePicker from '@/components/ExpirePicker.vue'
import ResultCard from '@/components/ResultCard.vue'
import FileDrop from '@/components/FileDrop.vue'
import { shareText, shareFile } from '@/api/share'
import { initChunkUpload, uploadOneChunk, chunkStatus, finishChunkUpload, cancelChunkUpload } from '@/api/chunk'
import { uploadFileInChunks } from '@/utils/chunkUpload'
import { useConfigStore } from '@/stores/config'
import { useToastStore } from '@/stores/toast'
import { formatBytes } from '@/utils/format'
import { ApiError } from '@/api/http'
const { t } = useI18n()
const router = useRouter()
const config = useConfigStore()
const toast = useToastStore()
const tab = ref<'text' | 'file'>('text')
// ---- 快速取件 ----
const pickupCode = ref('')
function goPickup(): void {
const code = pickupCode.value.trim()
if (!code) {
toast.error(t('home.pickupRequired'))
return
}
void router.push({ name: 'pickup', params: { code } })
}
// ---- 分享公共状态 ----
const expireValue = ref(1)
const expireStyle = ref('day')
const submitting = ref(false)
const shareResult = ref<{ code: string; name?: string } | null>(null)
function errMsg(e: unknown, fallback: string): string {
if (e instanceof ApiError) {
if (e.code === 423) return t('home.rateLimited')
if (e.code === 428) return t('home.notInitialized')
return e.msg || fallback
}
return fallback
}
// ---- 自定义提取码(v3.1):两个分享 tab 共用 ----
const customCode = ref('')
const CODE_RE = /^[A-Za-z0-9]{4,8}$/
// ---- 文本分享 ----
const TEXT_MAX_BYTES = 222 * 1024
const text = ref('')
const textBytes = computed(() => new TextEncoder().encode(text.value).length)
const textOver = computed(() => textBytes.value > TEXT_MAX_BYTES)
async function submitText(): Promise<void> {
const content = text.value
if (!content.trim()) {
toast.error(t('home.textRequired'))
return
}
if (textOver.value) {
toast.error(t('home.textTooLong'))
return
}
if (customCode.value.trim() && !CODE_RE.test(customCode.value.trim())) {
toast.error(t('home.customCodeInvalid'))
return
}
submitting.value = true
try {
const res = await shareText(content, expireValue.value, expireStyle.value, customCode.value.trim())
shareResult.value = { code: res.code }
toast.success(t('home.textShared'))
} catch (e) {
toast.error(errMsg(e, t('home.shareFailed')))
} finally {
submitting.value = false
}
}
// ---- 文件分享 ----
const file = ref<File | null>(null)
const uploadPercent = ref<number | null>(null)
const chunkSession = ref<{ cancel: () => void } | null>(null)
const canUpload = computed(() => config.openUpload)
const useChunk = computed(() => Boolean(file.value && config.enableChunk && file.value.size > 8 * 1024 * 1024))
/** 需求⑧:大小上限用 max_file_size0=回落 uploadSize */
const maxSizeBytes = computed(() => config.effectiveMaxFileSize)
async function submitFile(): Promise<void> {
if (!file.value) {
toast.error(t('home.fileRequired'))
return
}
if (maxSizeBytes.value && file.value.size > maxSizeBytes.value) {
toast.error(t('home.fileTooLarge', { size: formatBytes(maxSizeBytes.value) }))
return
}
submitting.value = true
uploadPercent.value = 0
shareResult.value = null
try {
if (customCode.value.trim() && !CODE_RE.test(customCode.value.trim())) {
toast.error(t('home.customCodeInvalid'))
return
}
if (useChunk.value) {
// 大文件:分片上传(断点续传/并发/失败重试)
const session = uploadFileInChunks(
{
file: file.value,
expireValue: expireValue.value,
expireStyle: expireStyle.value,
customCode: customCode.value.trim(),
onProgress: (p) => (uploadPercent.value = p),
},
{ init: initChunkUpload, uploadOne: uploadOneChunk, status: chunkStatus, finish: finishChunkUpload, cancel: cancelChunkUpload },
)
chunkSession.value = session
const res = await session.promise
shareResult.value = { code: res.code, name: res.name }
} else {
const res = await shareFile(file.value, expireValue.value, expireStyle.value, (p) => (uploadPercent.value = p), customCode.value.trim())
shareResult.value = { code: res.code, name: res.name || file.value.name }
}
toast.success(t('home.fileShared'))
file.value = null
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') toast.info(t('home.uploadCancelled'))
else toast.error(errMsg(e, t('home.uploadFailed')))
} finally {
submitting.value = false
uploadPercent.value = null
chunkSession.value = null
}
}
function cancelUpload(): void {
chunkSession.value?.cancel()
}
function switchTab(tabName: 'text' | 'file'): void {
tab.value = tabName
shareResult.value = null
uploadPercent.value = null
}
</script>
<template>
<PageShell>
<section class="hero">
<h1>{{ t('home.heroTitle', { name: config.displayName }) }}</h1>
<p>{{ config.description || t('home.heroDesc') }}</p>
<form class="quick-pickup" @submit.prevent="goPickup">
<input
v-model="pickupCode"
class="input"
:placeholder="t('home.pickupPlaceholder')"
maxlength="32"
autocomplete="off"
/>
<button class="btn" type="submit">{{ t('home.pickupButton') }}</button>
</form>
</section>
<section class="card share-card">
<div class="tabs" role="tablist">
<button class="tab" :class="{ active: tab === 'text' }" type="button" role="tab" @click="switchTab('text')">
{{ t('home.tabText') }}
</button>
<button class="tab" :class="{ active: tab === 'file' }" type="button" role="tab" @click="switchTab('file')">
{{ t('home.tabFile') }}
</button>
</div>
<!-- 分享文本 -->
<form v-if="tab === 'text'" style="margin-top: 18px" @submit.prevent="submitText">
<div class="field">
<label>{{ t('home.textContent') }}</label>
<textarea
v-model="text"
class="textarea"
:placeholder="t('home.textPlaceholder')"
spellcheck="false"
></textarea>
<p class="hint" :style="textOver ? 'color: var(--c-danger)' : ''">
{{ t('home.textBytes', { bytes: textBytes.toLocaleString() }) }}
</p>
</div>
<div class="field">
<label for="share-custom-code">{{ t('home.customCode') }}</label>
<input
id="share-custom-code"
v-model="customCode"
class="input input-mono"
:placeholder="t('home.customCodeHint')"
maxlength="8"
autocomplete="off"
/>
</div>
<div class="field">
<ExpirePicker v-model:value="expireValue" v-model:style="expireStyle" />
</div>
<button class="btn btn-block" type="submit" :disabled="submitting || textOver">
<span v-if="submitting" class="spin" aria-hidden="true"></span>
{{ t('home.generateCode') }}
</button>
</form>
<!-- 分享文件 -->
<form v-else style="margin-top: 18px" @submit.prevent="submitFile">
<template v-if="canUpload">
<div class="field">
<FileDrop
v-model="file"
:max-size="maxSizeBytes || undefined"
:accept-types="config.allowedFileTypes"
:disabled="submitting"
/>
</div>
<div class="field">
<div class="field">
<label for="share-custom-code-file">{{ t('home.customCode') }}</label>
<input
id="share-custom-code-file"
v-model="customCode"
class="input input-mono"
:placeholder="t('home.customCodeHint')"
maxlength="8"
autocomplete="off"
/>
</div>
<ExpirePicker v-model:value="expireValue" v-model:style="expireStyle" />
</div>
<div v-if="uploadPercent !== null" class="field">
<div class="progress"><i :style="{ width: `${uploadPercent}%` }"></i></div>
<p class="hint">
{{ useChunk ? t('home.chunkedUploading') : t('home.uploading') }} {{ uploadPercent }}%
<button v-if="submitting" class="btn btn-ghost btn-sm" type="button" style="margin-left: 8px" @click="cancelUpload">
{{ t('common.cancel') }}
</button>
</p>
</div>
<button class="btn btn-block" type="submit" :disabled="submitting || !file">
<span v-if="submitting" class="spin" aria-hidden="true"></span>
{{ submitting ? t('home.uploadingDots') : t('home.uploadAndShare') }}
</button>
</template>
<div v-else class="empty">
<div class="empty-icon">🚫</div>
{{ t('home.uploadDisabled') }}
</div>
</form>
<!-- 分享成功结果 -->
<div v-if="shareResult" style="margin-top: 18px">
<ResultCard
:code="shareResult.code"
:name="shareResult.name"
:expire-value="expireValue"
:expire-style="expireStyle"
/>
<button class="btn btn-ghost btn-block" type="button" style="margin-top: 12px" @click="shareResult = null">
{{ t('home.shareAnother') }}
</button>
</div>
</section>
</PageShell>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import PageShell from '@/components/PageShell.vue'
const { t } = useI18n()
</script>
<template>
<PageShell>
<div class="card empty" style="max-width: 480px; margin: 10vh auto 0">
<div class="empty-icon">🧭</div>
<p style="font-weight: 600; color: var(--c-text)">{{ t('notFound.title') }}</p>
<p class="hint">{{ t('notFound.desc') }}</p>
<RouterLink class="btn" to="/" style="margin-top: 10px">{{ t('notFound.back') }}</RouterLink>
</div>
</PageShell>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
/** /openapi 页:内嵌 Swagger UI 渲染 docs/openapi.yaml(构建时嵌入优先,运行时回退远端) */
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PageShell from '@/components/PageShell.vue'
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js'
import 'swagger-ui-dist/swagger-ui.css'
import { embeddedOpenApiYaml } from '@/docs/docsSource'
import { remoteOpenApiUrl } from '@/api/paths'
const { t } = useI18n()
const containerId = 'swagger-ui-container'
const status = ref<'loading' | 'ok' | 'error'>('loading')
const embedded = ref(false)
const sourceText = computed(() =>
embedded.value ? t('openapi.sourceEmbedded') : remoteOpenApiUrl() || '/docs/openapi.yaml',
)
onMounted(() => {
try {
let url: string
if (embeddedOpenApiYaml) {
// 内嵌 yaml → Blob URL(离线可用;Swagger UI 自行解析 YAML
embedded.value = true
url = URL.createObjectURL(new Blob([embeddedOpenApiYaml], { type: 'application/yaml' }))
} else {
url = remoteOpenApiUrl() || '/docs/openapi.yaml'
}
SwaggerUI({
dom_id: `#${containerId}`,
url,
deepLinking: true,
docExpansion: 'list',
tryItOutEnabled: true,
onComplete: () => (status.value = 'ok'),
onFailure: () => (status.value = 'error'),
})
// 给 url 模式一个超时兜底:Swagger UI 内部失败不一定触发 onFailure
setTimeout(() => {
if (status.value === 'loading') {
const hasContent = document.querySelector(`#${containerId} .opblock`)
status.value = hasContent ? 'ok' : 'error'
}
}, 6000)
} catch {
status.value = 'error'
}
})
const statusText = computed(() =>
status.value === 'ok' ? t('openapi.statusOk') : status.value === 'error' ? t('openapi.statusError') : t('openapi.statusLoading'),
)
</script>
<template>
<PageShell>
<div class="openapi-head">
<h2 style="margin: 0; font-size: 19px">{{ t('openapi.title') }}</h2>
<span class="badge" :class="status === 'ok' ? 'badge-success' : status === 'error' ? 'badge-danger' : 'badge-muted'">
{{ statusText }}
</span>
<span class="hint" style="margin: 0">{{ t('openapi.source', { source: sourceText }) }}</span>
</div>
<div v-if="status === 'error'" class="card empty" style="margin-top: 14px">
<div class="empty-icon">🧾</div>
<p style="font-weight: 600; color: var(--c-text)">{{ t('openapi.notAvailable') }}</p>
<p class="hint" style="max-width: 460px; margin: 0 auto">
{{ t('openapi.notAvailableHint', { url: remoteOpenApiUrl() || '/docs/openapi.yaml' }) }}
</p>
</div>
<div class="card openapi-wrap" style="padding: 0; overflow: hidden">
<div :id="containerId"></div>
</div>
</PageShell>
</template>
<style scoped>
.openapi-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.openapi-wrap :deep(.swagger-ui) {
border: none;
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
/** 取件页 /s/:code:输入取件码 → 文本展示 / 文件下载(需求⑩:i18n) */
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import PageShell from '@/components/PageShell.vue'
import { getMetadata, pickupText, pickupFileWithProgress } from '@/api/share'
import type { ShareMetadata } from '@/api/types'
import { ApiError } from '@/api/http'
import { useToastStore } from '@/stores/toast'
import { copyText, downloadBlob, formatBytes, formatDateTime, formatRemaining } from '@/utils/format'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToastStore()
// 兼容历史「链接+提取码」复制格式:/s/XVR3H%20XVR3H → 取第一段有效码
const code = computed(() => String(route.params.code ?? '').trim().split(/\s+/)[0])
const phase = ref<'loading' | 'ready' | 'error'>('loading')
const errorMsg = ref('')
const meta = ref<ShareMetadata | null>(null)
const textContent = ref('')
const textLoading = ref(false)
const downloadPercent = ref<number | null>(null)
async function load(): Promise<void> {
if (!code.value) {
phase.value = 'error'
errorMsg.value = t('pickup.emptyCode')
return
}
phase.value = 'loading'
errorMsg.value = ''
meta.value = null
textContent.value = ''
try {
const m = await getMetadata(code.value)
meta.value = m
phase.value = 'ready'
if (m.isText) {
textLoading.value = true
try {
textContent.value = await pickupText(code.value)
} finally {
textLoading.value = false
}
}
} catch (e) {
phase.value = 'error'
errorMsg.value = e instanceof ApiError
? e.code === 404
? t('pickup.notFound')
: e.code === 423
? t('home.rateLimited')
: e.code === 428
? t('home.notInitialized')
: e.msg
: t('pickup.failedDefault')
}
}
onMounted(load)
watch(code, () => void load())
// 语言切换时刷新错误文案(错误态静态文案)
watch(() => t('pickup.emptyCode'), () => {
if (phase.value === 'error' && errorMsg.value) void load()
})
async function download(): Promise<void> {
if (!meta.value) return
downloadPercent.value = 0
try {
const { blob, filename } = await pickupFileWithProgress(code.value, (p) => (downloadPercent.value = p))
downloadBlob(blob, filename || meta.value.name || 'download')
toast.success(t('pickup.downloaded'))
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('pickup.downloadFailed'))
} finally {
downloadPercent.value = null
}
}
async function copyContent(): Promise<void> {
const ok = await copyText(textContent.value)
ok ? toast.success(t('pickup.copied')) : toast.error(t('common.copyFailed'))
}
function downloadText(): void {
if (!meta.value) return
const blob = new Blob([textContent.value], { type: 'text/plain;charset=utf-8' })
const name = meta.value.name?.includes('.') ? meta.value.name : `${meta.value.name || 'text'}.txt`
downloadBlob(blob, name)
}
const retryCode = ref('')
function retry(): void {
const c = retryCode.value.trim()
if (!c) return
void router.push({ name: 'pickup', params: { code: c } })
}
const remaining = computed(() => {
const m = meta.value
if (!m) return ''
if (m.remainingDownloads === null || m.remainingDownloads === undefined) return t('pickup.remainingUnlimited')
return m.remainingDownloads < 0 ? t('pickup.remainingUnlimited') : t('pickup.remainingCount', { n: m.remainingDownloads })
})
</script>
<template>
<PageShell>
<section class="card" style="max-width: 640px; margin: 12px auto 0">
<template v-if="phase === 'loading'">
<div class="loading-block"><span class="spin" aria-hidden="true"></span> {{ t('pickup.querying', { code }) }}</div>
</template>
<template v-else-if="phase === 'error'">
<div class="empty">
<div class="empty-icon">📮</div>
<p style="font-weight: 600; color: var(--c-text)">{{ errorMsg || t('pickup.failed') }}</p>
<p class="hint">{{ t('pickup.confirmHint') }}</p>
</div>
<form class="quick-pickup" style="margin-top: 6px; max-width: none" @submit.prevent="retry">
<input v-model="retryCode" class="input input-mono" :placeholder="t('pickup.retryPlaceholder')" maxlength="32" />
<button class="btn" type="submit">{{ t('pickup.retryButton') }}</button>
</form>
</template>
<template v-else-if="meta">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 4px">
<span class="badge">{{ meta.isText ? t('common.text') : t('common.file') }}</span>
<strong style="font-size: 16px; word-break: break-all">{{ meta.name }}</strong>
<span v-if="!meta.isText" class="hint" style="margin: 0">{{ formatBytes(meta.size) }}</span>
</div>
<p class="hint" style="margin-bottom: 18px">
{{ remaining }} · {{ t('pickup.expireAt', { time: meta.expiredAt ? formatDateTime(meta.expiredAt) : t('time.permanent') }) }} ·
{{ formatRemaining(meta.expiredAt) }}
</p>
<!-- 文本取件 -->
<template v-if="meta.isText">
<div v-if="textLoading" class="loading-block"><span class="spin" aria-hidden="true"></span> {{ t('pickup.loadingText') }}</div>
<template v-else>
<pre class="text-view">{{ textContent }}</pre>
<div style="display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap">
<button class="btn" type="button" @click="copyContent">{{ t('pickup.copyContent') }}</button>
<button class="btn btn-ghost" type="button" @click="downloadText">{{ t('pickup.downloadTxt') }}</button>
</div>
</template>
</template>
<!-- 文件取件 -->
<template v-else>
<div class="file-summary">
<span style="font-size: 30px" aria-hidden="true">📄</span>
<div>
<div style="font-weight: 600; word-break: break-all">{{ meta.name }}</div>
<div class="hint" style="margin: 0">{{ t('pickup.sizeUsed', { size: formatBytes(meta.size), n: meta.usedCount }) }}</div>
</div>
</div>
<div v-if="downloadPercent !== null" style="margin: 16px 0 6px">
<div class="progress"><i :style="{ width: `${downloadPercent}%` }"></i></div>
<p class="hint">{{ t('pickup.downloading', { percent: downloadPercent }) }}</p>
</div>
<button class="btn btn-block" type="button" :disabled="downloadPercent !== null" @click="download">
{{ t('pickup.downloadFile', { size: formatBytes(meta.size) }) }}
</button>
</template>
</template>
</section>
</PageShell>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
/** 管理后台布局:侧边菜单 + 会话校验(需求⑩:i18n) */
import { onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import SiteNav from '@/components/SiteNav.vue'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
const { t } = useI18n()
const router = useRouter()
const auth = useAuthStore()
const toast = useToastStore()
onMounted(async () => {
// 已持有 token 则静默校验;失效会被 http 拦截器踢回登录页
if (auth.isAuthed && !auth.checked) await auth.verify()
})
async function logout(): Promise<void> {
await auth.logout()
toast.success(t('admin.nav.loggedOut'))
void router.replace({ name: 'admin-login' })
}
</script>
<template>
<SiteNav />
<div class="admin-shell">
<aside class="admin-aside">
<div class="aside-title">{{ t('admin.nav.title') }}</div>
<nav class="aside-menu" :aria-label="t('admin.nav.menu')">
<RouterLink :to="{ name: 'admin-files' }">📁 {{ t('admin.nav.files') }}</RouterLink>
<RouterLink :to="{ name: 'admin-audit' }">🛡 {{ t('admin.nav.audit') }}</RouterLink>
<RouterLink :to="{ name: 'admin-settings' }"> {{ t('admin.nav.settings') }}</RouterLink>
<div class="aside-sep"></div>
<a href="#" @click.prevent="logout">🚪 {{ t('admin.nav.logout') }}</a>
</nav>
</aside>
<div class="admin-main">
<RouterView />
</div>
</div>
</template>
+218
View File
@@ -0,0 +1,218 @@
<script setup lang="ts">
/** 审计日志:表格 + 动作/结果/IP/时间范围过滤(需求③:管理端可查询上传下载审计;需求⑩:i18n) */
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAuditList } from '@/api/admin'
import type { AuditLogEntry } from '@/api/types'
import { ApiError } from '@/api/http'
import Pager from '@/components/Pager.vue'
import { useToastStore } from '@/stores/toast'
import { formatBytes, formatDateTime, formatDuration } from '@/utils/format'
const { t } = useI18n()
const toast = useToastStore()
const rows = ref<AuditLogEntry[]>([])
const total = ref(0)
const loading = ref(false)
const filters = reactive({
action: '',
result: '',
ip: '',
start: '',
end: '',
})
const query = reactive({ page: 1, size: 20 })
function fmtRangeInput(v: string, endOfDay = false): string {
// datetime-local → ISOend 当天补 23:59:59
if (!v) return ''
const d = new Date(v)
if (Number.isNaN(d.getTime())) return ''
if (endOfDay) d.setHours(23, 59, 59, 999)
return d.toISOString()
}
async function load(): Promise<void> {
loading.value = true
try {
const res = await adminAuditList({
page: query.page,
size: query.size,
action: filters.action || undefined,
result: filters.result || undefined,
ip: filters.ip.trim() || undefined,
startTime: fmtRangeInput(filters.start) || undefined,
endTime: fmtRangeInput(filters.end, true) || undefined,
})
rows.value = res.data
total.value = res.total
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.audit.loadFailed'))
} finally {
loading.value = false
}
}
function search(): void {
query.page = 1
void load()
}
function reset(): void {
filters.action = ''
filters.result = ''
filters.ip = ''
filters.start = ''
filters.end = ''
query.page = 1
void load()
}
function onPageChange(page: number, size: number): void {
query.page = page
query.size = size
void load()
}
function actionBadge(action: string): string {
if (action === 'upload') return t('admin.audit.actionUpload')
if (action === 'download') return t('admin.audit.actionDownload')
return action || '-'
}
function resultBadge(result: string): { cls: string; text: string } {
if (result === 'success') return { cls: 'badge-success', text: t('common.success') }
if (result === 'denied') return { cls: 'badge-warn', text: t('common.denied') }
if (result === 'failed') return { cls: 'badge-danger', text: t('common.failed') }
return { cls: 'badge-muted', text: result || '-' }
}
function deviceText(r: AuditLogEntry): string {
const parts = [r.deviceOs, r.deviceBrowser].filter(Boolean)
const type = r.deviceType ? ` · ${r.deviceType}` : ''
return parts.length ? parts.join(' · ') + type : '-'
}
function bytesText(r: AuditLogEntry): string {
const transferred = r.transferredBytes
if (transferred === null || transferred === undefined) return '-'
const size = r.sizeBytes
if (size !== null && size !== undefined && size !== transferred) {
return `${formatBytes(transferred)} / ${formatBytes(size)}`
}
return formatBytes(transferred)
}
onMounted(load)
</script>
<template>
<div>
<div class="toolbar">
<div>
<h2 class="page-title">{{ t('admin.audit.title') }}</h2>
<p class="page-sub">{{ t('admin.audit.subtitle') }}</p>
</div>
</div>
<form class="filter-bar" @submit.prevent="search">
<label>
<span>{{ t('admin.audit.action') }}</span>
<select v-model="filters.action" class="select">
<option value="">{{ t('common.all') }}</option>
<option value="upload">{{ t('admin.audit.actionUpload') }}</option>
<option value="download">{{ t('admin.audit.actionDownload') }}</option>
</select>
</label>
<label>
<span>{{ t('admin.audit.result') }}</span>
<select v-model="filters.result" class="select">
<option value="">{{ t('common.all') }}</option>
<option value="success">{{ t('common.success') }}</option>
<option value="failed">{{ t('common.failed') }}</option>
<option value="denied">{{ t('common.denied') }}</option>
</select>
</label>
<label>
<span>{{ t('admin.audit.filterIp') }}</span>
<input v-model="filters.ip" class="input" placeholder="10.0.0.1" style="max-width: 150px" />
</label>
<label>
<span>{{ t('admin.audit.filterStart') }}</span>
<input v-model="filters.start" class="input" type="datetime-local" />
</label>
<label>
<span>{{ t('admin.audit.filterEnd') }}</span>
<input v-model="filters.end" class="input" type="datetime-local" />
</label>
<button class="btn" type="submit">{{ t('common.query') }}</button>
<button class="btn btn-ghost" type="button" @click="reset">{{ t('common.reset') }}</button>
</form>
<div v-if="loading" class="loading-block"><span class="spin" aria-hidden="true"></span> {{ t('common.loading') }}</div>
<div v-else-if="!rows.length" class="card empty">
<div class="empty-icon">🛡</div>
{{ t('admin.audit.empty') }}
</div>
<div v-else class="table-wrap">
<table class="table">
<thead>
<tr>
<th>{{ t('admin.audit.colTime') }}</th>
<th>{{ t('admin.audit.colAction') }}</th>
<th>{{ t('admin.audit.colResult') }}</th>
<th>{{ t('admin.audit.colFile') }}</th>
<th>{{ t('admin.audit.colCode') }}</th>
<th>{{ t('admin.audit.colBytes') }}</th>
<th>{{ t('admin.audit.colIp') }}</th>
<th>{{ t('admin.audit.colDevice') }}</th>
<th>{{ t('admin.audit.colDuration') }}</th>
<th>{{ t('admin.audit.colUaError') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rows" :key="r.id">
<td>{{ formatDateTime(r.createdAt) }}</td>
<td>
<span class="badge" :class="r.action === 'upload' ? '' : 'badge-muted'">{{ actionBadge(r.action) }}</span>
</td>
<td><span class="badge" :class="resultBadge(r.result).cls">{{ resultBadge(r.result).text }}</span></td>
<td class="wrap" :title="r.fileName">{{ r.fileName || '-' }}</td>
<td class="code-cell">{{ r.fileCode || '-' }}</td>
<td>{{ bytesText(r) }}</td>
<td class="ip-cell">{{ r.ip || '-' }}</td>
<td>{{ deviceText(r) }}</td>
<td>{{ formatDuration(r.durationMs) }}</td>
<td class="wrap ua-cell" :title="r.errorMsg || r.userAgent">
{{ r.errorMsg || r.userAgent || '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<Pager :page="query.page" :size="query.size" :total="total" @change="onPageChange" />
</div>
</template>
<style scoped>
.filter-bar {
display: flex;
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
background: var(--glass-bg-soft);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 16px;
}
.filter-bar label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; color: var(--c-text-2); font-weight: 600; }
.filter-bar .select, .filter-bar .input { min-width: 130px; }
.ip-cell { font-family: var(--mono); font-size: 12.5px; }
.ua-cell { max-width: 260px; overflow: hidden; text-overflow: ellipsis; color: var(--c-text-3); }
</style>
+277
View File
@@ -0,0 +1,277 @@
<script setup lang="ts">
/** 文件管理:列表 / 搜索 / 编辑(取件码、过期)/ 复制链接 / 删除 / 批量删除(需求⑩:i18n) */
import { onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminFileBatchDelete, adminFileDelete, adminFileList, adminFileUpdate } from '@/api/admin'
import type { AdminFileInfo, AdminFileUpdatePayload } from '@/api/types'
import { ApiError } from '@/api/http'
import { pickupPageUrl } from '@/api/paths'
import { pickupText } from '@/api/share'
import Pager from '@/components/Pager.vue'
import AppModal from '@/components/AppModal.vue'
import { useToastStore } from '@/stores/toast'
import { useConfigStore } from '@/stores/config'
import { copyText, downloadBlob, formatBytes, formatDateTime } from '@/utils/format'
const { t } = useI18n()
const config = useConfigStore()
const toast = useToastStore()
const rows = ref<AdminFileInfo[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, size: 10, keyword: '' })
const selected = ref<Set<number>>(new Set())
async function load(): Promise<void> {
loading.value = true
try {
const res = await adminFileList({ ...query })
rows.value = res.data
total.value = res.total
selected.value = new Set()
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.files.loadFailed'))
} finally {
loading.value = false
}
}
function search(): void {
query.page = 1
void load()
}
function onPageChange(page: number, size: number): void {
query.page = page
query.size = size
void load()
}
function toggleSelect(id: number): void {
const next = new Set(selected.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selected.value = next
}
const allChecked = () => rows.value.length > 0 && rows.value.every((r) => selected.value.has(r.id))
function toggleAll(): void {
selected.value = allChecked() ? new Set() : new Set(rows.value.map((r) => r.id))
}
async function removeRow(row: AdminFileInfo): Promise<void> {
if (!window.confirm(t('admin.files.confirmDelete', { name: row.name || row.code }))) return
try {
await adminFileDelete(row.id)
toast.success(t('admin.files.deleteSuccess'))
void load()
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.files.deleteFailed'))
}
}
async function batchRemove(): Promise<void> {
if (!selected.value.size) return
if (!window.confirm(t('admin.files.confirmBatchDelete', { count: selected.value.size }))) return
try {
await adminFileBatchDelete([...selected.value])
toast.success(t('admin.files.batchDeleteSuccess'))
void load()
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.files.batchDeleteFailed'))
}
}
// ---- 编辑弹窗 ----
const editOpen = ref(false)
const editSaving = ref(false)
const editForm = reactive<{ id: number; code: string; expired_at: string; expired_count: number | null; original: AdminFileInfo | null }>({
id: 0,
code: '',
expired_at: '',
expired_count: null,
original: null,
})
function openEdit(row: AdminFileInfo): void {
editForm.id = row.id
editForm.code = row.code
editForm.original = row
editForm.expired_at = row.expiredAt ? toLocalInput(row.expiredAt) : ''
editForm.expired_count = row.expiredCount
editOpen.value = true
}
function toLocalInput(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const p = (n: number) => `${n}`.padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
}
async function saveEdit(): Promise<void> {
const orig = editForm.original
if (!orig) return
const payload: AdminFileUpdatePayload = { id: editForm.id }
if (editForm.code.trim() && editForm.code.trim() !== orig.code) payload.code = editForm.code.trim()
const newCount = editForm.expired_count
if (newCount !== null && newCount !== orig.expiredCount) payload.expired_count = newCount
if (editForm.expired_at) {
const iso = new Date(editForm.expired_at).toISOString()
if (iso !== orig.expiredAt) payload.expired_at = iso
}
if (Object.keys(payload).length === 1) {
toast.info(t('admin.files.nothingChanged'))
editOpen.value = false
return
}
editSaving.value = true
try {
await adminFileUpdate(payload)
toast.success(t('admin.files.updateSuccess'))
editOpen.value = false
void load()
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.files.updateFailed'))
} finally {
editSaving.value = false
}
}
// ---- 快捷操作 ----
async function copyLink(row: AdminFileInfo): Promise<void> {
const ok = await copyText(pickupPageUrl(row.code, config.shareLinkBase))
ok ? toast.success(t('admin.files.linkCopied')) : toast.error(t('common.copyFailed'))
}
async function copyCode(row: AdminFileInfo): Promise<void> {
const ok = await copyText(row.code)
ok ? toast.success(t('admin.files.codeCopied')) : toast.error(t('common.copyFailed'))
}
async function saveTextFile(row: AdminFileInfo): Promise<void> {
try {
const content = await pickupText(row.code)
downloadBlob(new Blob([content], { type: 'text/plain;charset=utf-8' }), row.name || `${row.code}.txt`)
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.files.fetchTextFailed'))
}
}
function remainingText(row: AdminFileInfo): string {
if (row.expiredCount === null || row.expiredCount < 0) return t('admin.files.remainingUnlimited')
return t('admin.files.remainingCount', { n: row.expiredCount })
}
onMounted(load)
</script>
<template>
<div>
<div class="toolbar">
<div>
<h2 class="page-title">{{ t('admin.files.title') }}</h2>
<p class="page-sub">{{ t('admin.files.totalRecords', { total }) }}</p>
</div>
<form class="toolbar-actions" @submit.prevent="search">
<input v-model="query.keyword" class="input" :placeholder="t('admin.files.searchPlaceholder')" style="max-width: 220px" />
<button class="btn" type="submit">{{ t('common.search') }}</button>
<button class="btn btn-ghost" type="button" @click="load">{{ t('common.refresh') }}</button>
<button
class="btn btn-danger-ghost"
type="button"
:disabled="!selected.size"
:title="selected.size ? t('admin.files.deleteSelectedTitle', { count: selected.size }) : t('admin.files.selectFirst')"
@click="batchRemove"
>
{{ selected.size ? t('admin.files.batchDeleteWithCount', { count: selected.size }) : t('admin.files.batchDelete') }}
</button>
</form>
</div>
<div v-if="loading" class="loading-block"><span class="spin" aria-hidden="true"></span> {{ t('admin.files.loading') }}</div>
<div v-else-if="!rows.length" class="card empty">
<div class="empty-icon">🗂</div>
{{ t('admin.files.empty') }}
</div>
<div v-else class="table-wrap">
<table class="table">
<thead>
<tr>
<th style="width: 36px"><input type="checkbox" :checked="allChecked()" @change="toggleAll" /></th>
<th>{{ t('admin.files.colCode') }}</th>
<th>{{ t('admin.files.colName') }}</th>
<th>{{ t('admin.files.colType') }}</th>
<th>{{ t('admin.files.colSize') }}</th>
<th>{{ t('admin.files.colUsed') }}</th>
<th>{{ t('admin.files.colRemaining') }}</th>
<th>{{ t('admin.files.colExpireAt') }}</th>
<th>{{ t('admin.files.colStatus') }}</th>
<th>{{ t('admin.files.colCreatedAt') }}</th>
<th style="min-width: 210px">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td><input type="checkbox" :checked="selected.has(row.id)" @change="toggleSelect(row.id)" /></td>
<td class="code-cell">{{ row.code }}</td>
<td class="wrap" :title="row.name">{{ row.name || '-' }}</td>
<td><span class="badge" :class="row.isText ? 'badge-muted' : ''">{{ row.isText ? t('common.text') : t('common.file') }}</span></td>
<td>{{ row.isText ? '-' : formatBytes(row.size) }}</td>
<td>{{ row.usedCount }}</td>
<td>{{ remainingText(row) }}</td>
<td>{{ row.expiredAt ? formatDateTime(row.expiredAt) : t('time.permanent') }}</td>
<td>
<span class="badge" :class="row.isExpired ? 'badge-danger' : 'badge-success'">
{{ row.isExpired ? t('admin.files.statusExpired') : t('admin.files.statusValid') }}
</span>
</td>
<td>{{ formatDateTime(row.createdAt) }}</td>
<td>
<div class="row-actions">
<button class="btn btn-ghost btn-sm" type="button" @click="copyCode(row)">{{ t('admin.files.copyCode') }}</button>
<button class="btn btn-ghost btn-sm" type="button" @click="copyLink(row)">{{ t('admin.files.copyLink') }}</button>
<button class="btn btn-ghost btn-sm" type="button" @click="openEdit(row)">{{ t('admin.files.edit') }}</button>
<button v-if="row.isText" class="btn btn-ghost btn-sm" type="button" @click="saveTextFile(row)">{{ t('admin.files.fetchText') }}</button>
<button class="btn btn-danger-ghost btn-sm" type="button" @click="removeRow(row)">{{ t('admin.files.delete') }}</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Pager :page="query.page" :size="query.size" :total="total" @change="onPageChange" />
<AppModal :open="editOpen" :title="t('admin.files.editModalTitle')" @close="editOpen = false">
<form @submit.prevent="saveEdit">
<div class="field">
<label>{{ t('admin.files.colCode') }}</label>
<input v-model="editForm.code" class="input input-mono" maxlength="32" />
</div>
<div class="field">
<label>{{ t('admin.files.expireAtHint') }}</label>
<input v-model="editForm.expired_at" class="input" type="datetime-local" />
</div>
<div class="field">
<label>{{ t('admin.files.expireCountHint') }}</label>
<input
class="input"
type="number"
:value="editForm.expired_count ?? -1"
@input="editForm.expired_count = Number(($event.target as HTMLInputElement).value)"
/>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" type="button" @click="editOpen = false">{{ t('common.cancel') }}</button>
<button class="btn" type="submit" :disabled="editSaving">{{ t('common.save') }}</button>
</div>
</form>
</AppModal>
</div>
</template>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
/** 管理员登录页(需求⑩:i18n) */
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import PageShell from '@/components/PageShell.vue'
import { useAuthStore } from '@/stores/auth'
import { useConfigStore } from '@/stores/config'
import { ApiError } from '@/api/http'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const config = useConfigStore()
const password = ref('')
const submitting = ref(false)
const error = ref('')
async function submit(): Promise<void> {
if (!password.value) {
error.value = t('admin.login.required')
return
}
submitting.value = true
error.value = ''
try {
await auth.login(password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/admin/files'
void router.replace(redirect)
} catch (e) {
error.value = e instanceof ApiError ? (e.code === 401 ? t('admin.login.wrongPassword') : e.msg) : t('admin.login.failed')
password.value = ''
} finally {
submitting.value = false
}
}
</script>
<template>
<PageShell>
<section class="card" style="max-width: 380px; margin: 8vh auto 0">
<div class="login-head">
<img :src="config.displayLogoUrl" alt="Logo" class="login-logo" />
<h1 class="card-title">{{ t('admin.login.title') }}</h1>
<p class="card-sub">{{ t('admin.login.subtitle', { name: config.displayName }) }}</p>
</div>
<form @submit.prevent="submit">
<div class="field">
<label for="admin-password">{{ t('admin.login.password') }}</label>
<input
id="admin-password"
v-model="password"
class="input"
type="password"
:placeholder="t('admin.login.passwordPlaceholder')"
autocomplete="current-password"
autofocus
/>
</div>
<p v-if="error" class="hint" style="color: var(--c-danger); margin-bottom: 12px">{{ error }}</p>
<button class="btn btn-block" type="submit" :disabled="submitting">
<span v-if="submitting" class="spin" aria-hidden="true"></span>
{{ t('admin.login.submit') }}
</button>
</form>
<p class="hint" style="margin-top: 16px">{{ t('admin.login.hint') }}</p>
</section>
</PageShell>
</template>
<style scoped>
.login-head { text-align: center; margin-bottom: 18px; }
.login-logo {
width: 48px;
height: 48px;
object-fit: contain;
margin-bottom: 8px;
}
</style>
+784
View File
@@ -0,0 +1,784 @@
<script setup lang="ts">
/**
* 系统设置(需求⑨):
* - v1:站点名称、Logo/favicon 自定义(上传或填写 URL)、管理员密码
* - v2 新增:背景图 URL、页脚文案/备案号、通知开关/标题/内容、
* 保存策略(max_save_seconds/max_save_count)、存储策略(max_file_size/allowed_file_types)、
* 上传频率限制(uploadCount/uploadMinute
* - v3 新增:策略单位友好化(保存时间 小时/天、文件大小 MB/GB,后端 canonical 单位不变);
* 存储引擎卡(local/webdav/s3 三选 + 引擎参数表单 + 热切换 POST /admin/storage/switch
* - 表单 min/max 对齐 server/internal/config/schema.go KVSchema 边界;保存调 PATCH /admin/config/update
* - 策略校验最终由服务端强制(400 中文错误),前端仅做边界约束与提示
*/
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { NSwitch } from 'naive-ui'
import { useRouter } from 'vue-router'
import { adminConfigGet, adminConfigUpdate, adminPasswordUpdate, adminStorageSwitch } from '@/api/admin'
import { ApiError } from '@/api/http'
import { useConfigStore } from '@/stores/config'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { pick } from '@/utils/format'
import { formatBytes } from '@/utils/format'
const { t } = useI18n()
const toast = useToastStore()
const config = useConfigStore()
const auth = useAuthStore()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
// —— KVSchema 边界(与 server/internal/config/schema.go 对齐)——
const MAX_SAVE_SECONDS_MAX = 365 * 24 * 60 * 60 // 31536000
const MAX_SAVE_COUNT_MAX = 100000
const MAX_FILE_SIZE_MAX = 10 * 1024 * 1024 * 1024 // 10 GiB
const NOTIFY_TITLE_MAX = 128
const NOTIFY_CONTENT_MAX = 2000
const FOOTER_TEXT_MAX = 2000
const FOOTER_BEIAN_MAX = 128
const BACKGROUND_URL_MAX = 2048
const UPLOAD_COUNT_MAX = 10000
const UPLOAD_MINUTE_MAX = 1440
const form = reactive({
site_name: '',
site_domain: '',
logo_url: '',
favicon_url: '',
// v2
background_url: '',
footer_text: '',
footer_beian: '',
notify_enabled: true,
notify_title: '',
notify_content: '',
max_save_seconds: 0,
max_save_count: 0,
max_file_size: 0,
allowed_file_types: '',
uploadCount: 0,
uploadMinute: 0,
})
// —— v3:友好单位状态(canonical 单位仍为秒/字节,换算在前端)——
// 保存时间:值 + 单位(hour|day);文件大小:值 + 单位(MB|GB,1024 进制)
const saveTimeValue = ref(0)
const saveTimeUnit = ref<'hour' | 'day'>('day')
const fileSizeValue = ref(0)
const fileSizeUnit = ref<'MB' | 'GB'>('MB')
const HOUR_SECONDS = 3600
const DAY_SECONDS = 86400
const MB_BYTES = 1024 * 1024
const GB_BYTES = 1024 * 1024 * 1024
/** 秒 → {value, unit}:≥1 天显示天,否则小时(0 归 0) */
function secondsToFriendly(sec: number): { value: number; unit: 'hour' | 'day' } {
if (sec <= 0) return { value: 0, unit: 'day' }
if (sec % DAY_SECONDS === 0 || sec >= DAY_SECONDS) {
const days = sec / DAY_SECONDS
if (Number.isInteger(days)) return { value: days, unit: 'day' }
}
return { value: Math.round((sec / HOUR_SECONDS) * 100) / 100, unit: 'hour' }
}
/** 字节 → {value, unit}:≥1 GiB 显示 GB,否则 MB0 归 0 */
function bytesToFriendly(b: number): { value: number; unit: 'MB' | 'GB' } {
if (b <= 0) return { value: 0, unit: 'MB' }
if (b % GB_BYTES === 0 || b >= GB_BYTES) {
const gb = b / GB_BYTES
if (Number.isInteger(gb)) return { value: gb, unit: 'GB' }
}
return { value: Math.round((b / MB_BYTES) * 100) / 100, unit: 'MB' }
}
/** 友好值 → 秒(clamp 到 KVSchema 上限;负数归 0=不限制) */
function friendlyToSeconds(value: number, unit: 'hour' | 'day'): number {
const sec = unit === 'day' ? value * DAY_SECONDS : value * HOUR_SECONDS
return clamp(sec, 0, MAX_SAVE_SECONDS_MAX)
}
/** 友好值 → 字节(clamp 到 KVSchema 上限;负数归 0=回落 uploadSize */
function friendlyToBytes(value: number, unit: 'MB' | 'GB'): number {
const b = unit === 'GB' ? value * GB_BYTES : value * MB_BYTES
return clamp(b, 0, MAX_FILE_SIZE_MAX)
}
const saveTimeUnlimited = computed(() => saveTimeValue.value <= 0)
const fileSizeUnlimited = computed(() => fileSizeValue.value <= 0)
const LOGO_MAX_BYTES = 256 * 1024
function toBool(v: unknown, fallback: boolean): boolean {
if (v === undefined || v === null) return fallback
if (typeof v === 'boolean') return v
if (typeof v === 'number') return v !== 0
return String(v) !== '0' && String(v) !== 'false' && String(v) !== ''
}
function toNum(v: unknown, fallback: number): number {
const n = Number(v)
return Number.isFinite(n) ? n : fallback
}
function toList(v: unknown): string[] {
if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean)
if (typeof v === 'string') return v.split(',').map((x) => x.trim()).filter(Boolean)
return []
}
function clamp(v: number, min: number, max: number): number {
return Math.min(Math.max(Math.round(v) || 0, min), max)
}
async function load(): Promise<void> {
loading.value = true
try {
const cfg = await adminConfigGet()
form.site_name = String(pick<string>(cfg, ['site_name', 'name']) ?? '')
form.site_domain = String(pick<string>(cfg, ['site_domain', 'siteDomain']) ?? '')
form.logo_url = String(pick<string>(cfg, ['logo_url']) ?? '')
form.favicon_url = String(pick<string>(cfg, ['favicon_url']) ?? '')
form.background_url = String(pick<string>(cfg, ['background_url', 'backgroundUrl']) ?? '')
form.footer_text = String(pick<string>(cfg, ['footer_text', 'footerText']) ?? '')
form.footer_beian = String(pick<string>(cfg, ['footer_beian', 'footerBeian']) ?? '')
form.notify_enabled = toBool(pick<unknown>(cfg, ['notify_enabled', 'notifyEnabled']), true)
form.notify_title = String(pick<string>(cfg, ['notify_title', 'notifyTitle']) ?? '')
form.notify_content = String(pick<string>(cfg, ['notify_content', 'notifyContent']) ?? '')
form.max_save_seconds = toNum(pick<unknown>(cfg, ['max_save_seconds', 'maxSaveSeconds']), 0)
form.max_save_count = toNum(pick<unknown>(cfg, ['max_save_count', 'maxSaveCount']), 0)
form.max_file_size = toNum(pick<unknown>(cfg, ['max_file_size', 'maxFileSize']), 0)
// v3:秒/字节 → 友好单位
const st = secondsToFriendly(form.max_save_seconds)
saveTimeValue.value = st.value
saveTimeUnit.value = st.unit
const fs = bytesToFriendly(form.max_file_size)
fileSizeValue.value = fs.value
fileSizeUnit.value = fs.unit
const types = toList(pick<unknown>(cfg, ['allowed_file_types', 'allowedFileTypes']))
form.allowed_file_types = types.join(',')
form.uploadCount = toNum(pick<unknown>(cfg, ['uploadCount', 'upload_count']), 0)
form.uploadMinute = toNum(pick<unknown>(cfg, ['uploadMinute', 'upload_minute']), 0)
// v3:当前引擎(get 的 _engine_hint.storage_backend 或公开 config 的 storage_engine
const hint = pick<Record<string, unknown>>(cfg, ['_engine_hint'])
const backend = hint ? String(pick<string>(hint, ['storage_backend']) ?? '') : ''
engineCurrent.value = (backend || String(pick<string>(cfg, ['storage_engine']) ?? 'local')) as EngineName
engineSelected.value = engineCurrent.value
loadEngineParams(cfg)
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.settings.loadFailed'))
} finally {
loading.value = false
}
}
/** 本地图片 → dataURL(无需专用上传端点,直接作为 URL 值存入 KV,全站生效) */
function pickImage(target: 'logo' | 'favicon'): void {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
if (file.size > LOGO_MAX_BYTES) {
toast.error(t('admin.settings.imageTooLarge'))
return
}
const reader = new FileReader()
reader.onload = () => {
const dataUrl = String(reader.result ?? '')
if (target === 'logo') form.logo_url = dataUrl
else form.favicon_url = dataUrl
toast.success(t('admin.settings.imageLoaded'))
}
reader.onerror = () => toast.error(t('admin.settings.imageReadFailed'))
reader.readAsDataURL(file)
}
input.click()
}
async function save(): Promise<void> {
saving.value = true
try {
// 需求⑨:数值 clamp 到 KVSchema 边界;allowed_file_types 空列表按「*」提交(服务端要求非空列表)
const types = form.allowed_file_types
.split(',')
.map((x) => x.trim())
.filter(Boolean)
await adminConfigUpdate({
site_name: form.site_name.trim(),
site_domain: form.site_domain.trim(),
logo_url: form.logo_url.trim(),
favicon_url: form.favicon_url.trim(),
// 需求⑤⑥⑦⑨:背景/页脚/通知
background_url: form.background_url.trim(),
footer_text: form.footer_text.trim(),
footer_beian: form.footer_beian.trim(),
notify_enabled: form.notify_enabled ? 1 : 0,
notify_title: form.notify_title.trim(),
notify_content: form.notify_content.trim(),
// 保存策略(v3:友好单位换算回秒)
max_save_seconds: friendlyToSeconds(saveTimeValue.value, saveTimeUnit.value),
max_save_count: clamp(form.max_save_count, 0, MAX_SAVE_COUNT_MAX),
// 存储策略(v3:友好单位换算回字节)
max_file_size: friendlyToBytes(fileSizeValue.value, fileSizeUnit.value),
allowed_file_types: types.length ? types : ['*'],
// 上传频率限制(键名对齐参考既有 camelCase 键)
uploadCount: clamp(form.uploadCount || 0, 1, UPLOAD_COUNT_MAX),
uploadMinute: clamp(form.uploadMinute || 0, 1, UPLOAD_MINUTE_MAX),
})
toast.success(t('admin.settings.saved'))
await config.load() // 立即刷新导航 Logo / favicon / 站点名称 / 背景 / 页脚 / 通知
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.settings.saveFailed'))
} finally {
saving.value = false
}
}
function restoreDefaults(): void {
form.site_name = '文件快传'
form.site_domain = ''
form.logo_url = ''
form.favicon_url = ''
form.background_url = ''
form.footer_text = ''
form.footer_beian = ''
form.notify_enabled = true
form.notify_title = ''
form.notify_content = ''
form.max_save_seconds = 0
form.max_save_count = 0
form.max_file_size = 0
form.allowed_file_types = '*'
form.uploadCount = 10
form.uploadMinute = 1
// v3:单位状态复位
saveTimeValue.value = 0
saveTimeUnit.value = 'day'
fileSizeValue.value = 0
fileSizeUnit.value = 'MB'
toast.info(t('admin.settings.restoreDefaultsDone'))
}
// ---- v3:存储引擎 ----
type EngineName = 'local' | 's3' | 'webdav'
const SENSITIVE_MASK = '******'
const engineCurrent = ref<EngineName>('local')
const engineSelected = ref<EngineName>('local')
const engineSwitching = ref(false)
const engineParamsSaving = ref(false)
const engineParams = reactive({
// local
local_storage_path: '',
// webdav
webdav_url: '',
webdav_root_path: 'filebox_storage',
webdav_username: '',
webdav_password: '',
// s3
s3_endpoint_url: '',
s3_bucket_name: '',
s3_region_name: 'auto',
s3_access_key_id: '',
s3_secret_access_key: '',
aws_session_token: '',
s3_addressing_style: 'auto',
})
function loadEngineParams(cfg: Record<string, unknown>): void {
engineParams.local_storage_path = String(pick<string>(cfg, ['local_storage_path', 'localStoragePath']) ?? '')
engineParams.webdav_url = String(pick<string>(cfg, ['webdav_url', 'webdavUrl']) ?? '')
engineParams.webdav_root_path = String(pick<string>(cfg, ['webdav_root_path', 'webdavRootPath']) ?? 'filebox_storage')
engineParams.webdav_username = String(pick<string>(cfg, ['webdav_username', 'webdavUsername']) ?? '')
engineParams.webdav_password = String(pick<string>(cfg, ['webdav_password', 'webdavPassword']) ?? '')
engineParams.s3_endpoint_url = String(pick<string>(cfg, ['s3_endpoint_url', 's3EndpointUrl']) ?? '')
engineParams.s3_bucket_name = String(pick<string>(cfg, ['s3_bucket_name', 's3BucketName']) ?? '')
engineParams.s3_region_name = String(pick<string>(cfg, ['s3_region_name', 's3RegionName']) ?? 'auto')
engineParams.s3_access_key_id = String(pick<string>(cfg, ['s3_access_key_id', 's3AccessKeyId']) ?? '')
engineParams.s3_secret_access_key = String(pick<string>(cfg, ['s3_secret_access_key', 's3SecretAccessKey']) ?? '')
engineParams.aws_session_token = String(pick<string>(cfg, ['aws_session_token', 'awsSessionToken']) ?? '')
engineParams.s3_addressing_style = String(pick<string>(cfg, ['s3_addressing_style', 's3AddressingStyle']) ?? 'auto')
}
/** 敏感键提交规则:空或掩码 → 不提交该键(服务端跳过不覆盖) */
function secretOrSkip(v: string): string | undefined {
const s = v.trim()
if (s === '' || s === SENSITIVE_MASK) return undefined
return s
}
async function switchEngine(): Promise<void> {
engineSwitching.value = true
try {
const engine = await adminStorageSwitch(engineSelected.value)
engineCurrent.value = (String(engine) as EngineName) || engineSelected.value
toast.success(t('admin.settings.engineSwitchOk', { engine }))
await config.load() // 公开 config 的 storage_engine 同步刷新
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.settings.engineSwitchFail'))
} finally {
engineSwitching.value = false
}
}
/** 保存当前选中引擎的参数(敏感键空/掩码不提交) */
async function saveEngineParams(): Promise<void> {
engineParamsSaving.value = true
try {
const patch: Record<string, unknown> = {}
if (engineSelected.value === 'local') {
patch.local_storage_path = engineParams.local_storage_path.trim()
} else if (engineSelected.value === 'webdav') {
patch.webdav_url = engineParams.webdav_url.trim()
patch.webdav_root_path = engineParams.webdav_root_path.trim()
patch.webdav_username = engineParams.webdav_username.trim()
const pass = secretOrSkip(engineParams.webdav_password)
if (pass !== undefined) patch.webdav_password = pass
} else {
patch.s3_endpoint_url = engineParams.s3_endpoint_url.trim()
patch.s3_bucket_name = engineParams.s3_bucket_name.trim()
patch.s3_region_name = engineParams.s3_region_name.trim() || 'auto'
patch.s3_access_key_id = engineParams.s3_access_key_id.trim()
const sk = secretOrSkip(engineParams.s3_secret_access_key)
if (sk !== undefined) patch.s3_secret_access_key = sk
const tok = secretOrSkip(engineParams.aws_session_token)
if (tok !== undefined) patch.aws_session_token = tok
patch.s3_addressing_style = engineParams.s3_addressing_style
}
await adminConfigUpdate(patch)
toast.success(t('admin.settings.engineParamsSaved'))
} catch (e) {
toast.error(e instanceof ApiError ? e.msg : t('admin.settings.saveFailed'))
} finally {
engineParamsSaving.value = false
}
}
// ---- 修改管理员密码 ----
const pwdForm = reactive({ old: '', next: '', confirm: '' })
const pwdSaving = ref(false)
async function savePassword(): Promise<void> {
if (!pwdForm.old || !pwdForm.next) {
toast.error(t('admin.settings.pwdRequired'))
return
}
if (pwdForm.next.length < 6) {
toast.error(t('admin.settings.pwdTooShort'))
return
}
if (pwdForm.next !== pwdForm.confirm) {
toast.error(t('admin.settings.pwdMismatch'))
return
}
pwdSaving.value = true
try {
await adminPasswordUpdate(pwdForm.old, pwdForm.next)
toast.success(t('admin.settings.pwdChanged'))
pwdForm.old = pwdForm.next = pwdForm.confirm = ''
await auth.logout() // 服务端轮换 jwt_secret,旧 token 已失效
void router.replace({ name: 'admin-login' })
} catch (e) {
toast.error(e instanceof ApiError ? (e.code === 401 ? t('admin.settings.pwdWrong') : e.msg) : t('admin.settings.pwdChangeFailed'))
} finally {
pwdSaving.value = false
}
}
onMounted(load)
</script>
<template>
<div>
<div class="toolbar">
<div>
<h2 class="page-title">{{ t('admin.settings.title') }}</h2>
<p class="page-sub">{{ t('admin.settings.subtitle') }}</p>
</div>
<button class="btn btn-ghost" type="button" @click="restoreDefaults">{{ t('admin.settings.restoreDefaults') }}</button>
</div>
<div v-if="loading" class="loading-block"><span class="spin" aria-hidden="true"></span> {{ t('admin.settings.loading') }}</div>
<form v-else class="settings-grid" @submit.prevent="save">
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionBasic') }}</h3>
<div class="field">
<label for="set-site-name">{{ t('admin.settings.siteName') }}</label>
<input id="set-site-name" v-model="form.site_name" class="input" placeholder="文件快传" maxlength="64" />
<p class="hint">{{ t('admin.settings.siteNameHint') }}</p>
</div>
<div class="field">
<label for="set-site-domain">{{ t('admin.settings.siteDomain') }}</label>
<input id="set-site-domain" v-model="form.site_domain" class="input" placeholder="https://share.example.com" maxlength="256" />
<p class="hint">{{ t('admin.settings.siteDomainHint') }}</p>
</div>
</section>
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionLogo') }}</h3>
<div class="field">
<label for="set-logo-url">{{ t('admin.settings.logoUrl') }}</label>
<div class="logo-input-row">
<input id="set-logo-url" v-model="form.logo_url" class="input" placeholder="https://…/logo.svg" />
<button class="btn btn-ghost" type="button" @click="pickImage('logo')">{{ t('admin.settings.uploadImage') }}</button>
</div>
<p class="hint">{{ t('admin.settings.logoHint') }}</p>
</div>
<div class="logo-preview-row">
<div class="logo-box">
<img :src="form.logo_url || config.displayLogoUrl" alt="Logo preview" />
</div>
<div style="font-size: 13px; color: var(--c-text-2)">
{{ t('admin.settings.navPreview') }}
<span style="display: inline-flex; align-items: center; gap: 8px; font-weight: 700; color: var(--c-text)">
<img :src="form.logo_url || config.displayLogoUrl" alt="" style="width: 22px; height: 22px; object-fit: contain" />
{{ form.site_name || config.displayName }}
</span>
</div>
</div>
</section>
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionFavicon') }}</h3>
<div class="field">
<label for="set-favicon-url">{{ t('admin.settings.faviconUrl') }}</label>
<div class="logo-input-row">
<input id="set-favicon-url" v-model="form.favicon_url" class="input" placeholder="https://…/favicon.png" />
<button class="btn btn-ghost" type="button" @click="pickImage('favicon')">{{ t('admin.settings.uploadImage') }}</button>
</div>
<p class="hint">{{ t('admin.settings.faviconHint') }}</p>
</div>
<div class="logo-preview-row">
<div class="logo-box" style="width: 44px; height: 44px">
<img :src="form.favicon_url || config.displayFaviconUrl" alt="Favicon preview" />
</div>
<span style="font-size: 13px; color: var(--c-text-2)">{{ t('admin.settings.faviconPreviewHint') }}</span>
</div>
</section>
<!-- 需求⑨背景图 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionBackground') }}</h3>
<div class="field">
<label for="set-bg-url">{{ t('admin.settings.backgroundUrl') }}</label>
<input
id="set-bg-url"
v-model="form.background_url"
class="input"
placeholder="https://…/bg.jpg"
:maxlength="BACKGROUND_URL_MAX"
/>
<p class="hint">{{ t('admin.settings.backgroundHint') }}</p>
</div>
<div v-if="form.background_url.trim()" class="logo-box" style="width: 120px">
<img :src="form.background_url" alt="Background preview" style="width: 100%; height: 100%; object-fit: cover" />
</div>
</section>
<!-- 需求⑨页脚 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionFooter') }}</h3>
<div class="field">
<label for="set-footer-text">{{ t('admin.settings.footerText') }}</label>
<textarea
id="set-footer-text"
v-model="form.footer_text"
class="textarea"
style="min-height: 72px"
:maxlength="FOOTER_TEXT_MAX"
></textarea>
<p class="hint">{{ t('admin.settings.footerTextHint') }}</p>
</div>
<div class="field">
<label for="set-footer-beian">{{ t('admin.settings.footerBeian') }}</label>
<input id="set-footer-beian" v-model="form.footer_beian" class="input" :maxlength="FOOTER_BEIAN_MAX" />
<p class="hint">{{ t('admin.settings.footerBeianHint') }}</p>
</div>
</section>
<!-- 需求⑨系统通知 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionNotify') }}</h3>
<div class="field notify-switch-row">
<label for="set-notify-enabled">{{ t('admin.settings.notifyEnabled') }}</label>
<NSwitch id="set-notify-enabled" v-model:value="form.notify_enabled" size="small" />
</div>
<div class="field">
<label for="set-notify-title">{{ t('admin.settings.notifyTitle') }}</label>
<input id="set-notify-title" v-model="form.notify_title" class="input" :maxlength="NOTIFY_TITLE_MAX" />
<p class="hint">{{ t('admin.settings.notifyTitleHint') }}</p>
</div>
<div class="field">
<label for="set-notify-content">{{ t('admin.settings.notifyContent') }}</label>
<textarea
id="set-notify-content"
v-model="form.notify_content"
class="textarea"
style="min-height: 72px"
:maxlength="NOTIFY_CONTENT_MAX"
></textarea>
<p class="hint">{{ t('admin.settings.notifyContentHint') }}</p>
</div>
</section>
<!-- 需求⑨保存策略v3 友好单位小时/ -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionSavePolicy') }}</h3>
<div class="field">
<label for="set-max-save-time">{{ t('admin.settings.maxSaveTime') }}</label>
<div class="unit-row">
<input
id="set-max-save-time"
v-model.number="saveTimeValue"
class="input"
type="number"
:min="0"
step="1"
:placeholder="t('admin.settings.saveTimeUnlimited')"
/>
<select v-model="saveTimeUnit" class="input unit-select" aria-label="unit">
<option value="hour">{{ t('admin.settings.unitHour') }}</option>
<option value="day">{{ t('admin.settings.unitDay') }}</option>
</select>
</div>
<p class="hint">
{{ t('admin.settings.maxSaveTimeHint') }}
<template v-if="saveTimeUnlimited">{{ t('admin.settings.saveTimeUnlimited') }}</template>
</p>
</div>
<div class="field">
<label for="set-max-save-count">{{ t('admin.settings.maxSaveCount') }}</label>
<input
id="set-max-save-count"
v-model.number="form.max_save_count"
class="input"
type="number"
:min="0"
:max="MAX_SAVE_COUNT_MAX"
step="1"
/>
<p class="hint">{{ t('admin.settings.maxSaveCountHint', { max: MAX_SAVE_COUNT_MAX }) }}</p>
</div>
</section>
<!-- 需求⑨存储策略v3 友好单位MB/GB+ 引擎卡 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionStorage') }}</h3>
<div class="field">
<label for="set-max-file-size">{{ t('admin.settings.maxFileSizeFriendly') }}</label>
<div class="unit-row">
<input
id="set-max-file-size"
v-model.number="fileSizeValue"
class="input"
type="number"
:min="0"
step="1"
:placeholder="t('admin.settings.sizeUnlimited')"
/>
<select v-model="fileSizeUnit" class="input unit-select" aria-label="unit">
<option value="MB">{{ t('admin.settings.unitMB') }}</option>
<option value="GB">{{ t('admin.settings.unitGB') }}</option>
</select>
</div>
<p class="hint">
{{ t('admin.settings.maxFileSizeHintV3', { fallback: formatBytes(config.uploadSize) }) }}
<template v-if="fileSizeUnlimited">{{ t('admin.settings.sizeUnlimited') }}</template>
<template v-else>{{ t('admin.settings.approxSize', { size: formatBytes(friendlyToBytes(fileSizeValue, fileSizeUnit)) }) }}</template>
</p>
</div>
<div class="field">
<label for="set-allowed-types">{{ t('admin.settings.allowedFileTypes') }}</label>
<input id="set-allowed-types" v-model="form.allowed_file_types" class="input" placeholder="jpg, png, pdf / image/*, *" />
<p class="hint">{{ t('admin.settings.allowedFileTypesHint') }}</p>
</div>
</section>
<!-- v3存储引擎类型三选 + 参数 + 热切换 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionEngine') }}</h3>
<div class="field">
<label>{{ t('admin.settings.engineCurrent') }}: <strong>{{ engineCurrent }}</strong></label>
</div>
<div class="field">
<label for="set-engine-type">{{ t('admin.settings.sectionEngine') }}</label>
<div class="unit-row">
<select id="set-engine-type" v-model="engineSelected" class="input" style="flex: 1">
<option value="local">{{ t('admin.settings.engineLocal') }}</option>
<option value="webdav">{{ t('admin.settings.engineWebdav') }}</option>
<option value="s3">{{ t('admin.settings.engineS3') }}</option>
</select>
<button class="btn" type="button" :disabled="engineSwitching" @click="switchEngine">
<span v-if="engineSwitching" class="spin" aria-hidden="true"></span>
{{ t('admin.settings.engineSwitch') }}
</button>
</div>
<p class="hint">{{ t('admin.settings.engineSwitchFail') }}</p>
</div>
<h4 class="card-sub" style="margin-top: 8px">{{ t('admin.settings.engineParamsTitle') }}</h4>
<!-- local 参数 -->
<div v-if="engineSelected === 'local'" class="field">
<label for="set-local-root">{{ t('admin.settings.localRoot') }}</label>
<input id="set-local-root" v-model="engineParams.local_storage_path" class="input" placeholder="/app/data/storage" />
<p class="hint">{{ t('admin.settings.localRootHint') }}</p>
</div>
<!-- webdav 参数 -->
<template v-if="engineSelected === 'webdav'">
<div class="field">
<label for="set-webdav-url">{{ t('admin.settings.webdavUrl') }}</label>
<input id="set-webdav-url" v-model="engineParams.webdav_url" class="input" placeholder="https://dav.example.com/dav/" />
<p class="hint">{{ t('admin.settings.webdavUrlHint') }}</p>
</div>
<div class="field">
<label for="set-webdav-root">{{ t('admin.settings.webdavRoot') }}</label>
<input id="set-webdav-root" v-model="engineParams.webdav_root_path" class="input" placeholder="filebox_storage" />
<p class="hint">{{ t('admin.settings.webdavRootHint') }}</p>
</div>
<div class="field-row">
<div class="field">
<label for="set-webdav-user">{{ t('admin.settings.webdavUser') }}</label>
<input id="set-webdav-user" v-model="engineParams.webdav_username" class="input" autocomplete="off" />
</div>
<div class="field">
<label for="set-webdav-pass">{{ t('admin.settings.webdavPass') }}</label>
<input id="set-webdav-pass" v-model="engineParams.webdav_password" class="input" type="password" autocomplete="new-password" />
<p class="hint">{{ t('admin.settings.secretKeepHint') }}</p>
</div>
</div>
</template>
<!-- s3 参数 -->
<template v-if="engineSelected === 's3'">
<div class="field-row">
<div class="field">
<label for="set-s3-endpoint">{{ t('admin.settings.s3Endpoint') }}</label>
<input id="set-s3-endpoint" v-model="engineParams.s3_endpoint_url" class="input" placeholder="https://s3.example.com:9000" />
<p class="hint">{{ t('admin.settings.s3EndpointHint') }}</p>
</div>
<div class="field">
<label for="set-s3-bucket">{{ t('admin.settings.s3Bucket') }}</label>
<input id="set-s3-bucket" v-model="engineParams.s3_bucket_name" class="input" placeholder="filecodebox" />
</div>
</div>
<div class="field-row">
<div class="field">
<label for="set-s3-region">{{ t('admin.settings.s3Region') }}</label>
<input id="set-s3-region" v-model="engineParams.s3_region_name" class="input" placeholder="auto" />
</div>
<div class="field">
<label for="set-s3-style">{{ t('admin.settings.s3Style') }}</label>
<select id="set-s3-style" v-model="engineParams.s3_addressing_style" class="input">
<option value="auto">{{ t('admin.settings.styleAuto') }}</option>
<option value="path">{{ t('admin.settings.stylePath') }}</option>
<option value="virtual">{{ t('admin.settings.styleVirtual') }}</option>
</select>
</div>
</div>
<div class="field-row">
<div class="field">
<label for="set-s3-ak">{{ t('admin.settings.s3Ak') }}</label>
<input id="set-s3-ak" v-model="engineParams.s3_access_key_id" class="input" autocomplete="off" />
</div>
<div class="field">
<label for="set-s3-sk">{{ t('admin.settings.s3Sk') }}</label>
<input id="set-s3-sk" v-model="engineParams.s3_secret_access_key" class="input" type="password" autocomplete="new-password" />
<p class="hint">{{ t('admin.settings.secretKeepHint') }}</p>
</div>
</div>
<div class="field">
<label for="set-s3-token">{{ t('admin.settings.s3Token') }}</label>
<input id="set-s3-token" v-model="engineParams.aws_session_token" class="input" type="password" autocomplete="new-password" />
<p class="hint">{{ t('admin.settings.secretKeepHint') }}</p>
</div>
</template>
<div class="save-row">
<button class="btn" type="button" :disabled="engineParamsSaving" @click="saveEngineParams">
<span v-if="engineParamsSaving" class="spin" aria-hidden="true"></span>
{{ t('admin.settings.engineParamsSave') }}
</button>
</div>
</section>
<!-- 需求上传频率限制 -->
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionUploadRate') }}</h3>
<div class="field-row">
<div class="field">
<label for="set-upload-count">{{ t('admin.settings.uploadCount') }}</label>
<input
id="set-upload-count"
v-model.number="form.uploadCount"
class="input"
type="number"
:min="1"
:max="UPLOAD_COUNT_MAX"
step="1"
/>
<p class="hint">{{ t('admin.settings.uploadCountHint', { max: UPLOAD_COUNT_MAX }) }}</p>
</div>
<div class="field">
<label for="set-upload-minute">{{ t('admin.settings.uploadMinute') }}</label>
<input
id="set-upload-minute"
v-model.number="form.uploadMinute"
class="input"
type="number"
:min="1"
:max="UPLOAD_MINUTE_MAX"
step="1"
/>
<p class="hint">{{ t('admin.settings.uploadMinuteHint', { max: UPLOAD_MINUTE_MAX }) }}</p>
</div>
</div>
</section>
<div class="save-row">
<button class="btn" type="submit" :disabled="saving">
<span v-if="saving" class="spin" aria-hidden="true"></span>
{{ t('admin.settings.saveAll') }}
</button>
</div>
<section class="card">
<h3 class="card-title">{{ t('admin.settings.sectionPassword') }}</h3>
<p class="card-sub">{{ t('admin.settings.passwordHint') }}</p>
<form @submit.prevent="savePassword">
<div class="field">
<label for="pwd-old">{{ t('admin.settings.oldPassword') }}</label>
<input id="pwd-old" v-model="pwdForm.old" class="input" type="password" autocomplete="current-password" />
</div>
<div class="field">
<label for="pwd-new">{{ t('admin.settings.newPassword') }}</label>
<input id="pwd-new" v-model="pwdForm.next" class="input" type="password" autocomplete="new-password" />
</div>
<div class="field">
<label for="pwd-confirm">{{ t('admin.settings.confirmPassword') }}</label>
<input id="pwd-confirm" v-model="pwdForm.confirm" class="input" type="password" autocomplete="new-password" />
</div>
<div class="save-row">
<button class="btn" type="submit" :disabled="pwdSaving">
<span v-if="pwdSaving" class="spin" aria-hidden="true"></span>
{{ t('common.save') }}
</button>
</div>
</form>
</section>
</form>
</div>
</template>
<style scoped>
.settings-grid { display: grid; gap: 16px; }
.save-row { display: flex; justify-content: flex-end; }
.notify-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.notify-switch-row label { margin-bottom: 0; }
.unit-row { display: flex; gap: 8px; align-items: center; }
.unit-row .input { flex: 1; }
.unit-select { flex: 0 0 110px !important; }
</style>
+19
View File
@@ -0,0 +1,19 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 后端 API 根地址,如 http://127.0.0.1:8466;默认为空 = 同源(embed 部署) */
readonly VITE_API_BASE?: string
/** 置 1 时 dev server 启用内置 mock(无需启动后端) */
readonly VITE_MOCK?: string
/** 路由模式,默认 history(后端 NoRoute 已做 SPA 回退);后端无回退时设 'hash' */
readonly VITE_ROUTER_MODE?: 'history' | 'hash'
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' {
const SwaggerUI: (options: Record<string, unknown>) => unknown
export default SwaggerUI
}