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:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1,79 @@
|
||||
# 文件快传 前端(web/)
|
||||
|
||||
Vue 3 + Vite + TypeScript 单页应用:分享 / 取件 / 管理后台(文件管理、审计日志、系统设置)/ 站内文档。
|
||||
|
||||
v2(任务 t3):液态玻璃主题(亮/暗/跟随系统)、中英双语(vue-i18n)、本地 Logo/favicon 打包资源、
|
||||
背景图 / 页脚自定义 / 系统通知(读 `GET /api/v1/config` 下发的 v2 字段)、上传页策略动态渲染、
|
||||
管理设置页全量策略表单(PATCH `/admin/config/update`)。
|
||||
|
||||
## 页面
|
||||
|
||||
| 路由 | 说明 |
|
||||
|---|---|
|
||||
| `/` | 首页:文本/文件分享 Tab + 快速取件(有效期选项/大小/类型过滤按 config 动态渲染) |
|
||||
| `/s/:code` | 取件页:文本展示 / 文件下载(带进度) |
|
||||
| `/admin/login` | 管理员登录 |
|
||||
| `/admin/files` | 文件管理:列表/搜索/编辑/删除/批量删除 |
|
||||
| `/admin/audit` | 审计日志:动作/结果/IP/时间范围过滤 |
|
||||
| `/admin/settings` | 系统设置:站点名称、Logo/favicon、背景图、页脚、通知、保存/存储策略、上传频率、管理员密码 |
|
||||
| `/docs` | 站内 API 文档(渲染 `docs/api/*.md`,构建时内嵌 + 运行时回退) |
|
||||
| `/openapi` | Swagger UI(渲染 `docs/openapi.yaml`) |
|
||||
|
||||
## 主题与语言
|
||||
|
||||
- 主题三态(导航栏切换 + `localStorage` 键 `fcb_theme_mode`):浅色 / 深色 / 跟随系统(默认,`prefers-color-scheme`)。
|
||||
实际主题写入 `<html data-theme>`;Naive UI 组件接收 `darkTheme`。`index.html` 内联脚本做首帧防闪烁。
|
||||
- 语言(导航栏切换 + `localStorage` 键 `fcb_locale`):默认 `navigator.language`(zh 开头 → zh-CN,其余 → en-US)。
|
||||
文案在 `src/locales/`,新增文案先加 zh-CN 再同步 en-US。
|
||||
|
||||
## v2 配置字段(消费 `GET /api/v1/config`,管理端经 `PATCH /admin/config/update` 写回)
|
||||
|
||||
`background_url`、`footer_text`、`footer_beian`、`notify_enabled/notify_title/notify_content`、
|
||||
`max_file_size`、`max_save_seconds`、`max_save_count`、`allowedFileTypes`、`expireStyle`、
|
||||
`uploadSize`、`uploadCount/uploadMinute`、`site_name/logo_url/favicon_url`。
|
||||
策略最终由服务端强制(403/400 中文错误);前端只做体验层提示与动态选项渲染。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
npm ci # 安装依赖
|
||||
npm run dev # 开发服务器(默认代理 /api /share /chunk /presign /admin 到 http://127.0.0.1:8466)
|
||||
npm run build # 类型检查 + 产出 dist/(后端 embed 用)
|
||||
npm run preview # 预览构建产物
|
||||
```
|
||||
|
||||
## 环境变量(.env.local)
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `VITE_API_BASE` | 后端 API 根地址;默认空 = 同源(embed 部署)。如 `http://127.0.0.1:8466` |
|
||||
| `VITE_MOCK` | 置 `1` 启用内置 mock(无需后端) |
|
||||
| `VITE_ROUTER_MODE` | `history`(默认,后端 NoRoute 已做 SPA 回退)/ `hash`(后端无回退时) |
|
||||
|
||||
## API 契约(最终版,经队长终审裁定)
|
||||
|
||||
**业务 API 挂根路径;公共端点 `/api/v1/health`、`/api/v1/config` 例外。** 权威依据:`docs/api/*.md`(tech-writer 13 篇,概述 00-overview.md)与 `docs/openapi.yaml`,本节与其 100% 对齐;变更须同步文档并知会 go-api + tech-writer。
|
||||
- 统一响应 `{"code":200,"msg":"ok","data":...}`,HTTP 状态 = code;401 未登录(连续失败 423)、423 限流、428 未初始化。
|
||||
- 取件:`GET /share/metadata?code=` 不消耗次数;`GET /share/select?code=` 会**消耗**取件次数(文本 text/plain,文件流 200/206 支持 Range),文件名读 Content-Disposition。前端只在真正取件时调用 select。
|
||||
- 分片:`POST /chunk/upload/init`(JSON)→ `POST /chunk/upload/{uploadID}/{chunkIndex}`(multipart 仅 chunk 字段)→ `GET /chunk/upload/status/{uploadID}` → `POST /chunk/upload/complete/{uploadID}`;取消 `DELETE /chunk/upload/{uploadID}`。
|
||||
- 管理端:`POST /admin/login`、`GET /admin/verify`、`DELETE /admin/file/delete`、`POST /admin/file/batch-delete`、`PATCH /admin/file/update`、`PATCH|POST /admin/config/update`、`GET /admin/audit/list`(参数 page/size/action/result/ip/start_time/end_time,ISO 8601)、`PATCH /admin/settings/password`。
|
||||
|
||||
## 嵌入约定(go:embed)
|
||||
|
||||
- 构建产物输出到 `web/dist/`(入口 `web/dist/index.html`)。**注意**:`web/` 在 Go module(server/)之外,go:embed 无法直接引用 —— go-api 需在构建脚本中拷贝(如 `cp -a web/dist server/web/dist`)或以 `os.DirFS` 方式挂载;`base: '/'` + history 路由配合其 NoRoute SPA 回退(静态资源优先、GET+text/html 回 index.html、/api/* JSON 404)。
|
||||
- 如需改输出目录,调整 `vite.config.ts` 的 `build.outDir`。
|
||||
|
||||
## Logo 自定义(v2:本地资源兜底)
|
||||
|
||||
- 默认导航 Logo:`src/assets/brand/logo.svg`(构建内嵌;`DEFAULT_LOGO_URL`)
|
||||
- 默认 favicon:`src/assets/brand/favicon.png`(`index.html` + `DEFAULT_FAVICON_URL`)
|
||||
- 运行时优先读取公共配置 `logo_url` / `favicon_url` / `site_name`(管理端「系统设置」保存后立即全站生效),空值回退本地默认;不再依赖远程 URL。
|
||||
|
||||
## API 客户端
|
||||
|
||||
统一封装见 `src/api/paths.ts`(全部路径一处可改)与 `src/api/http.ts`(`{code,msg,data}` 解包、Bearer 认证、超时、401/428 处理)。
|
||||
|
||||
## 文档页约定(与 tech-writer)
|
||||
|
||||
- `docs/api/*.md`:每个文件一个章节,**一级标题(`# 标题`)即侧边栏章节名**;文件名(去掉 .md)作为路由 slug。构建时通过 `import.meta.glob` 内嵌原文,后端如挂载 `/docs/api/index.json`(`[{slug,title}]`)可补充远端章节。
|
||||
- `docs/openapi.yaml`:构建时内嵌给 Swagger UI;后端也可直接静态托管 `/docs/openapi.yaml`。
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="文件快传 - 开箱即用的文件快传系统" />
|
||||
<!-- 需求②:首帧防闪烁——读 localStorage 主题模式并写入 html[data-theme](App 启动后由 theme 模块接管) -->
|
||||
<script>
|
||||
;(function () {
|
||||
try {
|
||||
var m = localStorage.getItem('fcb_theme_mode')
|
||||
var dark = m === 'dark' || (m !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
} catch (e) {
|
||||
document.documentElement.dataset.theme = 'light'
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<!-- 需求④:默认 favicon 使用本地打包资源;管理端可在系统设置中自定义 favicon_url 全站替换 -->
|
||||
<link rel="icon" type="image/png" href="/src/assets/brand/favicon.png" />
|
||||
<title>文件快传 · 文件快传</title>
|
||||
<style>
|
||||
html {
|
||||
background: #eef1f6;
|
||||
}
|
||||
html[data-theme='dark'] {
|
||||
background: #0b0f1a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1960
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "filecodebox-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "FileCodeBox Go 版前端:分享 / 取件 / 管理后台 / 审计日志 / 站内文档",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^16.3.0",
|
||||
"naive-ui": "^2.45.3",
|
||||
"pinia": "^3.0.3",
|
||||
"swagger-ui-dist": "^5.28.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^9.14.4",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.0",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
+145
@@ -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>
|
||||
@@ -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-delete,body {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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/** 带上传进度的 POST(XMLHttpRequest,fetch 无法拿到上传进度) */
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -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/*.md(tech-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', // GET(admin_token 屏蔽、jwt_secret 不下发)
|
||||
adminConfigUpdate: '/admin/config/update', // PATCH 部分字段 JSON(POST 亦可)
|
||||
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`
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/** 简易模态框(Teleport 到 body,Esc 关闭) */
|
||||
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>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 有效期选择器(需求⑧:按 config 的 expireStyle / max_save_seconds / max_save_count 动态渲染):
|
||||
* - 选项来自 config.expireStyle 白名单
|
||||
* - style=count 时数值上限 = max_save_count(0=不限),并展示提示
|
||||
* - 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_count(0=不限) */
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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」被整串当码查询而 404(v3.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>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 顶部导航(需求⑥:只留主导航——API 文档/OpenAPI/管理后台移至页脚):
|
||||
* - 需求②:主题三态切换(亮/暗/跟随系统)+ localStorage 记忆
|
||||
* - 需求④:本地打包 Logo(config 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>
|
||||
@@ -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>
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* vue-i18n 装配(需求⑩):
|
||||
* - legacy:false(Composition API 模式)
|
||||
* - 默认语言:navigator.language(zh 开头 → 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
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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)或 MIME(image/*),* 不限制',
|
||||
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:9000(AWS 官方可留空)',
|
||||
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}',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 开发期 Mock(VITE_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)')
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: [
|
||||
// 需求⑩:标题键走 i18n(document.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
|
||||
@@ -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 */
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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-256(hex),用于分片上传 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
|
||||
}
|
||||
@@ -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)、站内相对路径、锚点与 mailto(src 另允许 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 }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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_size(0=回落 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 → ISO;end 当天补 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,否则 MB(0 归 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>
|
||||
Vendored
+19
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts", "mock/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
// go:embed + NoRoute SPA 回退(go-api 已实现):history 路由用绝对资源路径
|
||||
base: '/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// 站内文档(docs/api/*.md、docs/openapi.yaml)位于 web/ 之外,dev server 允许访问
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['.', '..'],
|
||||
},
|
||||
// 业务 API 在根路径(/share /chunk /presign /admin,最终版契约),公共接口在 /api/v1。
|
||||
// /admin 与前端 SPA 路由前缀重叠:GET 且 Accept 含 text/html(浏览器导航/刷新)时 bypass
|
||||
// 回 SPA,其余(fetch/XHR API 调用)代理到后端;embed 后由后端 NoRoute 回退兜底。
|
||||
proxy: import.meta.env?.VITE_API_BASE
|
||||
? undefined
|
||||
: {
|
||||
'/api': { target: 'http://127.0.0.1:8466', changeOrigin: true },
|
||||
'/share': { target: 'http://127.0.0.1:8466', changeOrigin: true },
|
||||
'/chunk': { target: 'http://127.0.0.1:8466', changeOrigin: true },
|
||||
'/presign': { target: 'http://127.0.0.1:8466', changeOrigin: true },
|
||||
'/admin': {
|
||||
target: 'http://127.0.0.1:8466',
|
||||
changeOrigin: true,
|
||||
bypass(req) {
|
||||
if (req.method === 'GET' && (req.headers.accept ?? '').includes('text/html')) {
|
||||
return '/index.html'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1600,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
swagger: ['swagger-ui-dist'],
|
||||
markdown: ['marked'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user