feat: s3协议-文件管理

This commit is contained in:
ggyy
2026-05-10 19:38:38 +08:00
parent 5a0f09d759
commit 2c3a3323dc
25 changed files with 1887 additions and 5 deletions

View File

@@ -17,6 +17,7 @@
"@tiptap/starter-kit": "^3.22.3",
"@tiptap/vue-3": "^3.22.3",
"@universal-middleware/core": "^0.4.17",
"aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.3",
"hono": "^4.12.8",
"pinyin-pro": "^3.28.1",
@@ -611,6 +612,8 @@
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw=="],

268
lib/s3/client.ts Normal file
View File

@@ -0,0 +1,268 @@
import { AwsClient } from "aws4fetch";
export interface S3ClientConfig {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
region?: string;
publicDomain?: string;
pathPrefix?: string;
cacheControl?: string;
}
/**
* 根据 S3 响应状态码生成用户友好的错误信息
*/
function s3ErrorMessage(status: number, operation: string, rawText?: string): string {
switch (status) {
case 400:
return `S3 ${operation}失败 (400):请求参数错误。请检查端点地址和存储桶名称格式是否正确。`;
case 403:
return `S3 ${operation}失败 (403):认证失败或权限不足。请检查 Access Key ID 和 Secret Access Key 是否正确,以及密钥是否拥有该存储桶的读写权限。`;
case 404:
return `S3 ${operation}失败 (404):存储桶或资源不存在。请检查存储桶名称是否正确,以及存储桶所在的区域是否与端点匹配。`;
case 405:
return `S3 ${operation}失败 (405):操作不被允许。请检查端点地址是否正确,部分存储服务对端点格式有特殊要求。`;
case 429:
return `S3 ${operation}失败 (429):请求过于频繁,已被限流。请稍后重试。`;
case 500:
case 502:
case 503:
return `S3 ${operation}失败 (${status}):存储服务暂时不可用。请稍后重试。如果持续出现此错误,请检查存储服务状态。`;
default:
return `S3 ${operation}失败 (${status})${rawText || "未知错误"}。请检查 S3 配置是否正确。`;
}
}
export class S3Client {
private client: AwsClient;
private config: S3ClientConfig;
constructor(config: S3ClientConfig) {
this.config = config;
this.client = new AwsClient({
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
service: "s3",
region: config.region || "auto",
});
}
/**
* Build the full S3 URL for a given key
*/
private buildUrl(key: string): string {
const endpoint = this.config.endpoint.replace(/\/$/, "");
const bucket = this.config.bucketName;
// Virtual-hosted-style: bucket already in hostname
// e.g., https://v50-free.s3.us-west-004.backblazeb2.com
// URL: https://v50-free.s3.us-west-004.backblazeb2.com/key
if (endpoint.includes(`://${bucket}.`)) {
return `${endpoint}/${key}`;
}
// Path-style: https://s3.amazonaws.com/bucket/key
return `${endpoint}/${bucket}/${key}`;
}
/**
* Build the public URL for accessing a file
*/
buildPublicUrl(key: string): string {
if (this.config.publicDomain) {
const domain = this.config.publicDomain.replace(/\/$/, "");
return `${domain}/${key}`;
}
return this.buildUrl(key);
}
/**
* Build the full key with optional path prefix
*/
buildKey(filename: string, path?: string): string {
const prefix = this.config.pathPrefix?.replace(/^\/|\/$/g, "") || "";
const dir = path?.replace(/^\/|\/$/g, "") || "";
let key = "";
if (prefix) key += prefix + "/";
if (dir) key += dir + "/";
key += filename;
return key;
}
/**
* Test S3 connectivity and permissions
* Attempts to list objects (max 1) to verify credentials and bucket access
*/
async testConnection(): Promise<{ ok: boolean; message: string }> {
const endpoint = this.config.endpoint.replace(/\/$/, "");
const bucket = this.config.bucketName;
// Build list-objects URL
let listUrl: string;
if (endpoint.includes(`://${bucket}.`)) {
listUrl = `${endpoint}/?list-type=2&max-keys=1`;
} else {
listUrl = `${endpoint}/${bucket}/?list-type=2&max-keys=1`;
}
try {
const response = await this.client.fetch(listUrl, { method: "GET" });
if (!response.ok) {
const text = await response.text().catch(() => "");
return { ok: false, message: s3ErrorMessage(response.status, "连接测试", text) };
}
return { ok: true, message: `连接成功!存储桶「${bucket}」可正常访问。` };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) {
return { ok: false, message: `S3 连接测试失败:无法连接到 ${endpoint}。请检查端点地址是否正确,以及网络连接是否正常。` };
}
return { ok: false, message: `S3 连接测试失败:${msg}` };
}
}
/**
* Upload a file to S3
*/
async putObject(key: string, body: ArrayBuffer | Uint8Array | string, contentType: string): Promise<Response> {
const url = this.buildUrl(key);
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": this.config.cacheControl || "public, max-age=31536000, immutable",
};
let response: Response;
try {
response = await this.client.fetch(url, {
method: "PUT",
headers,
body: typeof body === "string" ? body : body as any,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) {
throw new Error(`S3 上传失败:无法连接到存储服务。请检查端点地址是否正确,以及网络连接是否正常。`);
}
throw new Error(`S3 上传失败:${msg}`);
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(s3ErrorMessage(response.status, "上传", text));
}
return response;
}
/**
* Get an object from S3 (for proxying)
*/
async getObject(key: string): Promise<Response> {
const url = this.buildUrl(key);
let response: Response;
try {
response = await this.client.fetch(url, { method: "GET" });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) {
throw new Error(`S3 获取文件失败:无法连接到存储服务。请检查端点地址是否正确,以及网络连接是否正常。`);
}
throw new Error(`S3 获取文件失败:${msg}`);
}
return response;
}
/**
* Delete an object from S3
*/
async deleteObject(key: string): Promise<void> {
const url = this.buildUrl(key);
let response: Response;
try {
response = await this.client.fetch(url, { method: "DELETE" });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("fetch") || msg.includes("network") || msg.includes("ENOTFOUND")) {
throw new Error(`S3 删除失败:无法连接到存储服务。请检查端点地址是否正确,以及网络连接是否正常。`);
}
throw new Error(`S3 删除失败:${msg}`);
}
// 404 is expected for idempotent delete (file already gone), silently ignore
if (response.status === 404) {
return;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(s3ErrorMessage(response.status, "删除", text));
}
}
/**
* Check if an object exists (HEAD request)
*/
async headObject(key: string): Promise<{ exists: boolean; contentType?: string; contentLength?: number }> {
const url = this.buildUrl(key);
let response: Response;
try {
response = await this.client.fetch(url, { method: "HEAD" });
} catch {
return { exists: false };
}
// 404 means file does not exist
if (response.status === 404) {
return { exists: false };
}
// Other non-OK responses are real errors, not "not found"
if (!response.ok) {
throw new Error(s3ErrorMessage(response.status, "查询"));
}
return {
exists: true,
contentType: response.headers.get("Content-Type") || undefined,
contentLength: response.headers.get("Content-Length")
? parseInt(response.headers.get("Content-Length")!, 10)
: undefined,
};
}
}
/**
* Create an S3Client from a database config record
*/
export function createS3ClientFromConfig(config: {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
region?: string | null;
publicDomain?: string | null;
pathPrefix?: string | null;
cacheControl?: string | null;
}): S3Client {
return new S3Client({
endpoint: config.endpoint,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
bucketName: config.bucketName,
region: config.region || "auto",
publicDomain: config.publicDomain || undefined,
pathPrefix: config.pathPrefix || undefined,
cacheControl: config.cacheControl || "public, max-age=31536000, immutable",
});
}

View File

@@ -1,4 +1,4 @@
import type { PrismaClient } from "../../generated/prisma/client";
import type { PrismaClient, Prisma, CardStatus } from "../../generated/prisma/client";
export function listCardRecords(prisma: PrismaClient) {
return prisma.card.findMany({
@@ -76,10 +76,10 @@ export function listCardRecordsPaged(
pageSize: number;
},
) {
const where: import("../../generated/prisma/client").Prisma.CardWhereInput = {};
const where: Prisma.CardWhereInput = {};
if (params.productId) where.productId = params.productId;
if (params.batchNo) where.batchNo = { contains: params.batchNo };
if (params.status) where.status = params.status as import("../../generated/prisma/client").CardStatus;
if (params.status) where.status = params.status as CardStatus;
if (params.startDate || params.endDate) {
where.createdAt = {};
if (params.startDate) where.createdAt.gte = new Date(params.startDate);

136
modules/media/repository.ts Normal file
View File

@@ -0,0 +1,136 @@
import type { PrismaClient, Prisma } from "../../generated/prisma/client";
import type { MediaListFilters, MediaListResult, S3ConfigInput } from "./types";
// ─── S3Config ────────────────────────────────────────────
export function getS3ConfigRecord(prisma: PrismaClient) {
return prisma.s3Config.findUnique({ where: { id: 1 } });
}
export function upsertS3ConfigRecord(prisma: PrismaClient, input: S3ConfigInput) {
return prisma.s3Config.upsert({
where: { id: 1 },
create: {
id: 1,
endpoint: input.endpoint,
accessKeyId: input.accessKeyId,
secretAccessKey: input.secretAccessKey,
bucketName: input.bucketName,
region: input.region || "auto",
publicDomain: input.publicDomain || null,
pathPrefix: input.pathPrefix || null,
cacheControl: input.cacheControl || "public, max-age=31536000, immutable",
},
update: {
endpoint: input.endpoint,
accessKeyId: input.accessKeyId,
secretAccessKey: input.secretAccessKey,
bucketName: input.bucketName,
region: input.region || "auto",
publicDomain: input.publicDomain || null,
pathPrefix: input.pathPrefix || null,
cacheControl: input.cacheControl || "public, max-age=31536000, immutable",
},
});
}
// ─── Media ───────────────────────────────────────────────
export function createMediaRecord(
prisma: PrismaClient,
input: {
originalName: string;
storedName: string;
mimeType: string;
fileSize: number;
fileKey: string;
url: string;
thumbnailUrl?: string | null;
path?: string | null;
metadata?: string | null;
uploadedBy: number;
},
) {
return prisma.media.create({
data: {
originalName: input.originalName,
storedName: input.storedName,
mimeType: input.mimeType,
fileSize: input.fileSize,
fileKey: input.fileKey,
url: input.url,
thumbnailUrl: input.thumbnailUrl || null,
path: input.path || null,
metadata: input.metadata || null,
uploadedBy: input.uploadedBy,
},
});
}
export function deleteMediaRecord(prisma: PrismaClient, id: number) {
return prisma.media.delete({ where: { id } });
}
export function getMediaRecord(prisma: PrismaClient, id: number) {
return prisma.media.findUnique({ where: { id } });
}
export function getMediaByKey(prisma: PrismaClient, fileKey: string) {
return prisma.media.findUnique({ where: { fileKey } });
}
export async function listMediaRecords(
prisma: PrismaClient,
filters: MediaListFilters,
): Promise<MediaListResult> {
const page = Math.max(1, filters.page || 1);
const pageSize = Math.min(100, Math.max(1, filters.pageSize || 24));
const where: Prisma.MediaWhereInput = {};
if (filters.mimeType) {
where.mimeType = { startsWith: filters.mimeType };
}
if (filters.path !== undefined) {
where.path = filters.path || null;
}
if (filters.keyword) {
where.OR = [
{ originalName: { contains: filters.keyword } },
{ storedName: { contains: filters.keyword } },
];
}
const [items, total] = await Promise.all([
prisma.media.findMany({
where,
orderBy: { uploadedAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.media.count({ where }),
]);
return {
items: items.map((item) => ({
id: item.id,
originalName: item.originalName,
storedName: item.storedName,
mimeType: item.mimeType,
fileSize: item.fileSize,
fileKey: item.fileKey,
url: item.url,
thumbnailUrl: item.thumbnailUrl,
path: item.path,
metadata: item.metadata,
uploadedBy: item.uploadedBy,
uploadedAt: item.uploadedAt.toISOString(),
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}

215
modules/media/service.ts Normal file
View File

@@ -0,0 +1,215 @@
import { getContext } from "telefunc";
import type { PrismaClient } from "../../generated/prisma/client";
import { badRequestError, notFoundError } from "../../lib/app-error";
import { S3Client, createS3ClientFromConfig } from "../../lib/s3/client";
import { getAdminContext, logAdminOperation } from "../auth/service";
import {
getS3ConfigRecord,
upsertS3ConfigRecord,
createMediaRecord,
deleteMediaRecord,
getMediaRecord,
listMediaRecords,
} from "./repository";
import type { S3ConfigInput, MediaListFilters, MediaListResult, MediaItem } from "./types";
// ─── S3 Config ───────────────────────────────────────────
export async function getS3Config(prisma?: PrismaClient) {
const client = prisma ?? getContext<{ prisma: PrismaClient }>().prisma;
const record = await getS3ConfigRecord(client);
if (!record) return null;
return {
id: record.id,
endpoint: record.endpoint,
accessKeyId: record.accessKeyId,
secretAccessKey: record.secretAccessKey,
bucketName: record.bucketName,
region: record.region,
publicDomain: record.publicDomain,
pathPrefix: record.pathPrefix,
cacheControl: record.cacheControl,
};
}
export async function saveS3Config(input: S3ConfigInput) {
const adminContext = getAdminContext();
const { prisma } = adminContext;
const adminId = Number(adminContext.session?.user?.id);
// Validate input
if (!input.endpoint?.trim()) throw badRequestError("S3 端点不能为空", "ENDPOINT_REQUIRED");
if (!input.accessKeyId?.trim()) throw badRequestError("Access Key ID 不能为空", "ACCESS_KEY_REQUIRED");
if (!input.secretAccessKey?.trim()) throw badRequestError("Secret Access Key 不能为空", "SECRET_KEY_REQUIRED");
if (!input.bucketName?.trim()) throw badRequestError("存储桶名称不能为空", "BUCKET_REQUIRED");
// Normalize endpoint: remove trailing slash
const endpoint = input.endpoint.trim().replace(/\/+$/, "");
const record = await upsertS3ConfigRecord(prisma, {
endpoint,
accessKeyId: input.accessKeyId.trim(),
secretAccessKey: input.secretAccessKey.trim(),
bucketName: input.bucketName.trim(),
region: input.region?.trim() || "auto",
publicDomain: input.publicDomain?.trim() || undefined,
pathPrefix: input.pathPrefix?.trim().replace(/^\/|\/$/g, "") || undefined,
cacheControl: input.cacheControl?.trim() || "public, max-age=31536000, immutable",
});
await logAdminOperation(
{
action: "SAVE_S3_CONFIG",
targetType: "S3Config",
targetId: "1",
detail: `bucket=${record.bucketName}, endpoint=${record.endpoint}`,
},
{ prisma, adminId },
);
return {
id: record.id,
endpoint: record.endpoint,
accessKeyId: record.accessKeyId,
secretAccessKey: record.secretAccessKey,
bucketName: record.bucketName,
region: record.region,
publicDomain: record.publicDomain,
pathPrefix: record.pathPrefix,
cacheControl: record.cacheControl,
};
}
// ─── Get S3 Client from DB ───────────────────────────────
export async function getS3Client(prisma: PrismaClient): Promise<S3Client> {
const config = await getS3ConfigRecord(prisma);
if (!config) {
throw badRequestError("请先配置 S3 存储服务", "S3_CONFIG_MISSING");
}
return createS3ClientFromConfig(config);
}
export async function testS3Connection(input?: S3ConfigInput) {
const adminContext = getAdminContext();
const { prisma } = adminContext;
let s3Client: S3Client;
if (input) {
// Test with provided config (from form, not yet saved)
s3Client = createS3ClientFromConfig(input);
} else {
// Test with saved config
const config = await getS3ConfigRecord(prisma);
if (!config) {
throw badRequestError("请先配置 S3 存储服务", "S3_CONFIG_MISSING");
}
s3Client = createS3ClientFromConfig(config);
}
return s3Client.testConnection();
}
// ─── Media Operations ────────────────────────────────────
export async function listMedia(filters: MediaListFilters): Promise<MediaListResult> {
const adminContext = getAdminContext();
const { prisma } = adminContext;
return listMediaRecords(prisma, filters);
}
export async function deleteMedia(mediaId: number) {
const adminContext = getAdminContext();
const { prisma } = adminContext;
const adminId = Number(adminContext.session?.user?.id);
const media = await getMediaRecord(prisma, mediaId);
if (!media) {
throw notFoundError("文件不存在", "MEDIA_NOT_FOUND");
}
// Delete from S3
const s3Client = await getS3Client(prisma);
await s3Client.deleteObject(media.fileKey);
// Delete from database
await deleteMediaRecord(prisma, mediaId);
await logAdminOperation(
{
action: "DELETE_MEDIA",
targetType: "Media",
targetId: String(mediaId),
detail: `file=${media.originalName}, key=${media.fileKey}`,
},
{ prisma, adminId },
);
return { success: true, message: `已删除文件: ${media.originalName}` };
}
// ─── Used by HTTP upload route ───────────────────────────
export async function handleUpload(
prisma: PrismaClient,
adminId: number,
file: File,
path?: string,
): Promise<MediaItem> {
const s3Client = await getS3Client(prisma);
// Generate stored name with timestamp and random suffix
const ext = file.name.includes(".") ? "." + file.name.split(".").pop() : "";
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
const storedName = `${timestamp}-${random}${ext}`;
const fileKey = s3Client.buildKey(storedName, path);
// Read file content
const arrayBuffer = await file.arrayBuffer();
const contentType = file.type || "application/octet-stream";
// Upload to S3
await s3Client.putObject(fileKey, arrayBuffer, contentType);
// URL always goes through Worker proxy for Cloudflare caching
const url = `/api/media/proxy/${fileKey}`;
// Save to database
const media = await createMediaRecord(prisma, {
originalName: file.name,
storedName,
mimeType: contentType,
fileSize: file.size,
fileKey,
url,
path: path || null,
uploadedBy: adminId,
});
await logAdminOperation(
{
action: "UPLOAD_MEDIA",
targetType: "Media",
targetId: String(media.id),
detail: `file=${file.name}, size=${file.size}, type=${contentType}`,
},
{ prisma, adminId },
);
return {
id: media.id,
originalName: media.originalName,
storedName: media.storedName,
mimeType: media.mimeType,
fileSize: media.fileSize,
fileKey: media.fileKey,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
path: media.path,
metadata: media.metadata,
uploadedBy: media.uploadedBy,
uploadedAt: media.uploadedAt.toISOString(),
};
}

89
modules/media/types.ts Normal file
View File

@@ -0,0 +1,89 @@
export interface S3ConfigInput {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
region?: string;
publicDomain?: string;
pathPrefix?: string;
cacheControl?: string;
}
export interface MediaListFilters {
mimeType?: string;
path?: string;
keyword?: string;
page?: number;
pageSize?: number;
}
export interface MediaListResult {
items: MediaItem[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface MediaItem {
id: number;
originalName: string;
storedName: string;
mimeType: string;
fileSize: number;
fileKey: string;
url: string;
thumbnailUrl: string | null;
path: string | null;
metadata: string | null;
uploadedBy: number;
uploadedAt: string;
}
export interface UploadResult {
media: MediaItem;
message: string;
}
// Browser-renderable MIME types
export const RENDERABLE_MIME_TYPES = new Set([
// Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"image/bmp",
"image/ico",
// Videos
"video/mp4",
"video/webm",
"video/ogg",
// Audio
"audio/mpeg",
"audio/ogg",
"audio/wav",
"audio/webm",
// Documents
"application/pdf",
]);
export function isRenderableMimeType(mimeType: string): boolean {
return RENDERABLE_MIME_TYPES.has(mimeType);
}
export function getFileCategory(mimeType: string): string {
if (mimeType.startsWith("image/")) return "图片";
if (mimeType.startsWith("video/")) return "视频";
if (mimeType.startsWith("audio/")) return "音频";
if (mimeType === "application/pdf") return "PDF";
return "文件";
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}

View File

@@ -14,7 +14,7 @@
"verify:payments": "bun run scripts/verify-payment-adapters.ts",
"verify:payment-notify": "bun run scripts/verify-payment-notify.ts",
"deploy": "bun run db:migrations:remote && bun run db:seed:remote && wrangler deploy",
"up": "vike build && wrangler deploy",
"up": "bun run db:migrations:remote && vike build && wrangler deploy",
"types": "wrangler types"
},
"dependencies": {
@@ -31,6 +31,7 @@
"@tiptap/starter-kit": "^3.22.3",
"@tiptap/vue-3": "^3.22.3",
"@universal-middleware/core": "^0.4.17",
"aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.3",
"hono": "^4.12.8",
"pinyin-pro": "^3.28.1",

View File

@@ -102,6 +102,7 @@
<li><a href="/admin/orders" :class="{'active': currentPath?.startsWith('/admin/orders')}">订单管理</a></li>
<li><a href="/admin/payments" :class="{'active': currentPath?.startsWith('/admin/payments')}">支付配置</a></li>
<li><a href="/admin/email" :class="{'active': currentPath?.startsWith('/admin/email')}">邮件管理</a></li>
<li><a href="/admin/media" :class="{'active': currentPath?.startsWith('/admin/media')}">文件管理</a></li>
<li><a href="/admin/settings" :class="{'active': currentPath?.startsWith('/admin/settings')}">站点设置</a></li>
<!-- <li><a href="/admin/security" :class="{'active': currentPath?.startsWith('/admin/security')}">安全配置</a></li> -->
<li><a href="/admin/profile" :class="{'active': currentPath?.startsWith('/admin/profile')}">个人资料</a></li>
@@ -218,6 +219,7 @@ const BREADCRUMB_ROUTES: { pattern: string; crumbs: Crumb[] }[] = [
{ pattern: "/admin/cards", crumbs: [{ name: "卡密管理" }] },
{ pattern: "/admin/payments", crumbs: [{ name: "支付配置" }] },
{ pattern: "/admin/email", crumbs: [{ name: "邮件管理" }] },
{ pattern: "/admin/media", crumbs: [{ name: "文件管理" }] },
{ pattern: "/admin/settings", crumbs: [{ name: "站点设置" }] },
{ pattern: "/admin/security", crumbs: [{ name: "安全配置" }] },
{ pattern: "/admin/profile", crumbs: [{ name: "个人资料" }] },

305
pages/admin/media/+Page.vue Normal file
View File

@@ -0,0 +1,305 @@
<template>
<div class="space-y-6">
<!-- Header -->
<section class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between gap-4 max-md:flex-col max-md:items-start">
<div>
<h1 class="text-2xl font-bold">文件管理</h1>
<p class="text-sm text-base-content/70">管理上传到 S3 存储的文件资源</p>
</div>
<div class="flex items-center gap-2">
<AppButton variant="outline" size="sm" @click="showS3Config = true">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
S3 配置
</AppButton>
</div>
</div>
</div>
</section>
<!-- No S3 Config Warning -->
<div v-if="!s3Config" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
<div>
<h3 class="font-bold">尚未配置 S3 存储</h3>
<p class="text-sm">请先配置 S3 兼容存储服务 Cloudflare R2AWS S3 才能使用文件管理功能</p>
</div>
<AppButton variant="primary" size="sm" @click="showS3Config = true">立即配置</AppButton>
</div>
<!-- Upload Area -->
<section v-if="s3Config" class="card bg-base-100 shadow-sm">
<div class="card-body">
<MediaUploader
:uploading="uploading"
:upload-progress="uploadProgress"
:upload-error="uploadError"
@upload="handleUpload"
@clear-error="uploadError = ''"
/>
</div>
</section>
<!-- Filters & Search -->
<section v-if="s3Config" class="card bg-base-100 shadow-sm">
<div class="card-body py-3">
<MediaFilters
:keyword="filterKeyword"
:type-filter="filterType"
:total="mediaList.total"
@update:keyword="handleKeywordChange"
@update:type-filter="handleTypeFilterChange"
@refresh="refreshMediaList"
/>
</div>
</section>
<!-- Media Grid -->
<section v-if="s3Config" class="card bg-base-100 shadow-sm">
<div class="card-body">
<div v-if="loading" class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="mediaList.items.length === 0" class="text-center py-12">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<p class="text-base-content/50">暂无文件</p>
<p class="text-sm text-base-content/40 mt-1">上传文件后将在此处显示</p>
</div>
<MediaGrid
v-else
:items="mediaList.items"
@preview="handlePreview"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
<!-- Pagination -->
<div v-if="mediaList.totalPages > 1" class="flex justify-center mt-6">
<div class="join">
<button
class="join-item btn btn-sm"
:disabled="currentPage <= 1"
@click="goToPage(currentPage - 1)"
>
&laquo;
</button>
<button class="join-item btn btn-sm"> {{ currentPage }} / {{ mediaList.totalPages }} </button>
<button
class="join-item btn btn-sm"
:disabled="currentPage >= mediaList.totalPages"
@click="goToPage(currentPage + 1)"
>
&raquo;
</button>
</div>
</div>
</div>
</section>
<!-- Preview Modal -->
<MediaPreview
v-if="previewItem"
:item="previewItem"
@close="previewItem = null"
@copy-url="handleCopyUrl"
/>
<!-- S3 Config Modal -->
<S3ConfigModal
v-if="showS3Config"
:config="s3Config"
:saving="savingConfig"
:error="configError"
@close="showS3Config = false"
@save="handleSaveConfig"
/>
<!-- Toast -->
<div class="toast toast-end">
<div v-if="toastMessage" class="alert" :class="toastType === 'success' ? 'alert-success' : 'alert-error'">
<span>{{ toastMessage }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useData } from "vike-vue/useData";
import AppButton from "../../../components/AppButton.vue";
import MediaUploader from "./components/MediaUploader.vue";
import MediaGrid from "./components/MediaGrid.vue";
import MediaPreview from "./components/MediaPreview.vue";
import MediaFilters from "./components/MediaFilters.vue";
import S3ConfigModal from "./components/S3ConfigModal.vue";
import { normalizeTelefuncError } from "../../../lib/app-error";
import { onGetMediaList } from "./getMediaList.telefunc";
import { onDeleteMedia } from "./deleteMedia.telefunc";
import { onSaveS3Config } from "./saveS3Config.telefunc";
import type { Data } from "./+data";
import type { MediaItem, S3ConfigInput, MediaListResult } from "../../../modules/media/types";
const initialData = useData<Awaited<Data>>();
// State
const s3Config = ref(initialData.s3Config);
const mediaList = ref<MediaListResult>(initialData.mediaList);
const loading = ref(false);
const uploading = ref(false);
const uploadProgress = ref(0);
const uploadError = ref("");
const previewItem = ref<MediaItem | null>(null);
const showS3Config = ref(false);
const savingConfig = ref(false);
const configError = ref("");
const filterKeyword = ref("");
const filterType = ref("");
const currentPage = ref(1);
const toastMessage = ref("");
const toastType = ref<"success" | "error">("success");
// Functions
function showToast(message: string, type: "success" | "error" = "success") {
toastMessage.value = message;
toastType.value = type;
setTimeout(() => {
toastMessage.value = "";
}, 3000);
}
async function refreshMediaList() {
loading.value = true;
try {
const result = await onGetMediaList({
keyword: filterKeyword.value || undefined,
mimeType: filterType.value || undefined,
page: currentPage.value,
pageSize: 24,
});
mediaList.value = result;
} catch (error) {
showToast(normalizeTelefuncError(error, "加载失败"), "error");
} finally {
loading.value = false;
}
}
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleKeywordChange(keyword: string) {
filterKeyword.value = keyword;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage.value = 1;
refreshMediaList();
}, 300);
}
function handleTypeFilterChange(type: string) {
filterType.value = type;
currentPage.value = 1;
refreshMediaList();
}
function goToPage(page: number) {
currentPage.value = page;
refreshMediaList();
}
async function handleUpload(file: File) {
if (!s3Config.value) return;
uploading.value = true;
uploadProgress.value = 0;
uploadError.value = "";
try {
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/media/upload");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
uploadProgress.value = Math.round((e.loaded / e.total) * 100);
}
});
const response = await new Promise<{ success: boolean; data: MediaItem; message: string }>((resolve, reject) => {
xhr.onload = () => {
try {
const data = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
resolve(data);
} else {
reject(new Error(data.message || "上传失败"));
}
} catch {
reject(new Error("上传失败"));
}
};
xhr.onerror = () => reject(new Error("网络错误"));
xhr.send(formData);
});
showToast("文件上传成功");
// Add to list
mediaList.value.items.unshift(response.data);
mediaList.value.total += 1;
} catch (error) {
uploadError.value = normalizeTelefuncError(error, "上传失败");
} finally {
uploading.value = false;
uploadProgress.value = 0;
}
}
async function handleDelete(item: MediaItem) {
if (!confirm(`确定要删除「${item.originalName}」吗?此操作不可恢复。`)) return;
try {
await onDeleteMedia(item.id);
showToast("文件已删除");
mediaList.value.items = mediaList.value.items.filter((i) => i.id !== item.id);
mediaList.value.total -= 1;
if (previewItem.value?.id === item.id) {
previewItem.value = null;
}
} catch (error) {
showToast(normalizeTelefuncError(error, "删除失败"), "error");
}
}
function handlePreview(item: MediaItem) {
previewItem.value = item;
}
function handleCopyUrl(url: string) {
navigator.clipboard.writeText(url).then(() => {
showToast("URL 已复制到剪贴板");
}).catch(() => {
showToast("复制失败,请手动复制", "error");
});
}
async function handleSaveConfig(input: S3ConfigInput) {
savingConfig.value = true;
configError.value = "";
try {
const result = await onSaveS3Config(input);
s3Config.value = result;
showS3Config.value = false;
showToast("S3 配置已保存");
// Refresh media list after config change
refreshMediaList();
} catch (error) {
configError.value = normalizeTelefuncError(error, "保存失败");
} finally {
savingConfig.value = false;
}
}
</script>

View File

@@ -0,0 +1,17 @@
import type { PrismaClient } from "../../../generated/prisma/client";
import { getS3Config } from "../../../modules/media/service";
import { listMediaRecords } from "../../../modules/media/repository";
export type Data = ReturnType<typeof data>;
export async function data(pageContext: { prisma: PrismaClient }) {
const [s3Config, mediaList] = await Promise.all([
getS3Config(pageContext.prisma),
listMediaRecords(pageContext.prisma, { page: 1, pageSize: 24 }),
]);
return {
s3Config,
mediaList,
};
}

View File

@@ -0,0 +1,55 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<!-- Search -->
<div class="form-control flex-1 min-w-[200px]">
<div class="input-group">
<input
type="text"
:value="keyword"
placeholder="搜索文件名..."
class="input input-bordered input-sm w-full"
@input="$emit('update:keyword', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<!-- Type Filter -->
<select
class="select select-bordered select-sm"
:value="typeFilter"
@change="$emit('update:type-filter', ($event.target as HTMLSelectElement).value)"
>
<option value="">全部类型</option>
<option value="image/">图片</option>
<option value="video/">视频</option>
<option value="audio/">音频</option>
<option value="application/pdf">PDF</option>
</select>
<!-- Stats -->
<div class="text-sm text-base-content/60">
{{ total }} 个文件
</div>
<!-- Refresh -->
<button class="btn btn-ghost btn-sm" title="刷新" @click="$emit('refresh')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
keyword: string;
typeFilter: string;
total: number;
}>();
defineEmits<{
"update:keyword": [value: string];
"update:type-filter": [value: string];
refresh: [];
}>();
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div
v-for="item in items"
:key="item.id"
class="group relative card bg-base-200 hover:shadow-md transition-all cursor-pointer overflow-hidden"
@click="$emit('preview', item)"
>
<!-- Thumbnail / Icon -->
<div class="aspect-square flex items-center justify-center p-4 overflow-hidden">
<img
v-if="isImage(item.mimeType)"
:src="item.url"
:alt="item.originalName"
class="w-full h-full object-cover rounded"
loading="lazy"
/>
<video
v-else-if="isVideo(item.mimeType)"
:src="item.url"
class="w-full h-full object-cover rounded"
preload="metadata"
muted
/>
<div v-else class="text-center">
<svg v-if="isPdf(item.mimeType)" xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-xs text-base-content/50 mt-1 uppercase">{{ getExtension(item.originalName) }}</p>
</div>
</div>
<!-- Info Overlay -->
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-2">
<p class="text-white text-xs font-medium truncate">{{ item.originalName }}</p>
<p class="text-white/70 text-xs">{{ formatSize(item.fileSize) }}</p>
</div>
<!-- Action Buttons -->
<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
class="btn btn-xs btn-circle btn-ghost bg-white/80 hover:bg-white text-base-content"
title="复制 URL"
@click.stop="$emit('copy-url', item.url)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
<button
class="btn btn-xs btn-circle btn-ghost bg-error/80 hover:bg-error text-white"
title="删除"
@click.stop="$emit('delete', item)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { MediaItem } from "../../../../modules/media/types";
defineProps<{
items: MediaItem[];
}>();
defineEmits<{
preview: [item: MediaItem];
delete: [item: MediaItem];
"copy-url": [url: string];
}>();
function isImage(mimeType: string): boolean {
return mimeType.startsWith("image/");
}
function isVideo(mimeType: string): boolean {
return mimeType.startsWith("video/");
}
function isPdf(mimeType: string): boolean {
return mimeType === "application/pdf";
}
function getExtension(filename: string): string {
const ext = filename.split(".").pop();
return ext ? ext.toUpperCase() : "";
}
function formatSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<dialog class="modal modal-open" @click.self="$emit('close')">
<div class="modal-box max-w-4xl w-full max-h-[90vh] p-0 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-base-300">
<div class="min-w-0 flex-1">
<h3 class="font-bold text-lg truncate">{{ item.originalName }}</h3>
<p class="text-sm text-base-content/60">
{{ getFileCategory(item.mimeType) }} · {{ formatSize(item.fileSize) }}
</p>
</div>
<div class="flex items-center gap-2 ml-4">
<button class="btn btn-sm btn-ghost" title="复制 URL" @click="$emit('copy-url', item.url)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
复制 URL
</button>
<button class="btn btn-sm btn-ghost" @click="$emit('close')">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Preview Content -->
<div class="flex items-center justify-center bg-base-300 min-h-[300px] max-h-[calc(90vh-180px)] overflow-auto p-4">
<img
v-if="isImage(item.mimeType)"
:src="item.url"
:alt="item.originalName"
class="max-w-full max-h-full object-contain"
/>
<video
v-else-if="isVideo(item.mimeType)"
:src="item.url"
controls
class="max-w-full max-h-full"
>
您的浏览器不支持视频播放
</video>
<audio
v-else-if="isAudio(item.mimeType)"
:src="item.url"
controls
class="w-full max-w-md"
>
您的浏览器不支持音频播放
</audio>
<iframe
v-else-if="isPdf(item.mimeType)"
:src="item.url"
class="w-full h-full min-h-[500px]"
></iframe>
<div v-else class="text-center py-12">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-base-content/50">此文件类型无法预览</p>
<a :href="item.url" target="_blank" class="btn btn-primary btn-sm mt-4">在新标签页打开</a>
</div>
</div>
<!-- Details Footer -->
<div class="p-4 border-t border-base-300 bg-base-100">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
<div>
<span class="text-base-content/50">类型</span>
<p class="font-medium">{{ item.mimeType }}</p>
</div>
<div>
<span class="text-base-content/50">大小</span>
<p class="font-medium">{{ formatSize(item.fileSize) }}</p>
</div>
<div>
<span class="text-base-content/50">上传时间</span>
<p class="font-medium">{{ formatDate(item.uploadedAt) }}</p>
</div>
<div>
<span class="text-base-content/50">存储路径</span>
<p class="font-medium truncate">{{ item.path || '根目录' }}</p>
</div>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="$emit('close')">关闭</button>
</form>
</dialog>
</template>
<script setup lang="ts">
import type { MediaItem } from "../../../../modules/media/types";
defineProps<{
item: MediaItem;
}>();
defineEmits<{
close: [];
"copy-url": [url: string];
}>();
function isImage(mimeType: string): boolean {
return mimeType.startsWith("image/");
}
function isVideo(mimeType: string): boolean {
return mimeType.startsWith("video/");
}
function isAudio(mimeType: string): boolean {
return mimeType.startsWith("audio/");
}
function isPdf(mimeType: string): boolean {
return mimeType === "application/pdf";
}
function getFileCategory(mimeType: string): string {
if (mimeType.startsWith("image/")) return "图片";
if (mimeType.startsWith("video/")) return "视频";
if (mimeType.startsWith("audio/")) return "音频";
if (mimeType === "application/pdf") return "PDF";
return "文件";
}
function formatSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div>
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="{
'border-primary bg-primary/5': isDragging,
'border-base-300 hover:border-primary/50': !isDragging && !uploading,
'border-base-300 opacity-60 pointer-events-none': uploading,
}"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<div v-if="uploading" class="space-y-4">
<span class="loading loading-spinner loading-lg text-primary"></span>
<div>
<p class="font-medium">正在上传...</p>
<div class="w-full max-w-xs mx-auto mt-3">
<progress class="progress progress-primary w-full" :value="uploadProgress" max="100"></progress>
<p class="text-sm text-base-content/60 mt-1">{{ uploadProgress }}%</p>
</div>
</div>
</div>
<div v-else class="space-y-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div>
<p class="font-medium">拖拽文件到此处</p>
<label class="btn btn-primary btn-sm mt-2 cursor-pointer">
选择文件
<input type="file" class="hidden" @change="handleFileSelect" />
</label>
</div>
<p class="text-xs text-base-content/50">支持图片视频音频PDF 等浏览器可直接预览的文件格式</p>
</div>
</div>
<!-- Error Message -->
<div v-if="uploadError" class="alert alert-error mt-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-sm whitespace-pre-wrap">{{ uploadError }}</p>
</div>
<button class="btn btn-ghost btn-xs" @click="$emit('clear-error')">关闭</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const props = defineProps<{
uploading: boolean;
uploadProgress: number;
uploadError: string;
}>();
const emit = defineEmits<{
upload: [file: File];
"clear-error": [];
}>();
const isDragging = ref(false);
function handleDrop(e: DragEvent) {
isDragging.value = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
emit("upload", files[0]);
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
emit("upload", input.files[0]);
input.value = ""; // Reset so same file can be selected again
}
}
</script>

View File

@@ -0,0 +1,201 @@
<template>
<dialog class="modal modal-open" @click.self="$emit('close')">
<div class="modal-box max-w-2xl w-full">
<h3 class="font-bold text-lg mb-4">S3 存储配置</h3>
<div class="space-y-4">
<!-- Endpoint -->
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">S3 端点 <span class="text-error">*</span></span>
<input
v-model="form.endpoint"
class="input input-bordered w-full"
placeholder="https://s3.amazonaws.com"
/>
<span class="text-xs text-base-content/50">
示例Cloudflare R2: https://&lt;account_id&gt;.r2.cloudflarestorage.com | AWS S3: https://s3.amazonaws.com
</span>
</label>
<!-- Access Key -->
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Access Key ID <span class="text-error">*</span></span>
<input
v-model="form.accessKeyId"
class="input input-bordered w-full"
placeholder="AKIAIOSFODNN7EXAMPLE"
/>
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">Secret Access Key <span class="text-error">*</span></span>
<input
v-model="form.secretAccessKey"
type="password"
class="input input-bordered w-full"
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
/>
</label>
</div>
<!-- Bucket & Region -->
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">存储桶名称 <span class="text-error">*</span></span>
<input
v-model="form.bucketName"
class="input input-bordered w-full"
placeholder="my-bucket"
/>
</label>
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">区域</span>
<input
v-model="form.region"
class="input input-bordered w-full"
placeholder="auto"
/>
</label>
</div>
<!-- Public Domain -->
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">公开访问域名可选</span>
<input
v-model="form.publicDomain"
class="input input-bordered w-full"
placeholder="https://cdn.example.com"
/>
<span class="text-xs text-base-content/50">
如果配置了自定义域名或 CDN填写后文件将通过此域名访问
</span>
</label>
<!-- Path Prefix -->
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">路径前缀可选</span>
<input
v-model="form.pathPrefix"
class="input input-bordered w-full"
placeholder="media"
/>
<span class="text-xs text-base-content/50">
上传文件会自动添加此前缀例如填写 media文件将存储到 media/ 目录下
</span>
</label>
<!-- Cache Control -->
<label class="flex flex-col gap-1.5">
<span class="label-text font-medium">缓存策略</span>
<input
v-model="form.cacheControl"
class="input input-bordered w-full"
placeholder="public, max-age=31536000, immutable"
/>
<span class="text-xs text-base-content/50">
Cache-Control 用于 Cloudflare 缓存默认 1 年不变更缓存
</span>
</label>
</div>
<!-- Error -->
<div v-if="error" class="alert alert-error mt-4">
<span class="text-sm">{{ error }}</span>
</div>
<!-- Test Result -->
<div v-if="testResult" class="mt-4" :class="testResult.ok ? 'text-success' : 'text-error'">
<p class="text-sm">{{ testResult.message }}</p>
</div>
<!-- Actions -->
<div class="modal-action">
<AppButton variant="outline" :loading="testing" @click="handleTest">测试连接</AppButton>
<div class="flex-1"></div>
<button class="btn" @click="$emit('close')">取消</button>
<AppButton variant="primary" :loading="saving" @click="handleSave">保存配置</AppButton>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button @click="$emit('close')">关闭</button>
</form>
</dialog>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import AppButton from "../../../../components/AppButton.vue";
import { normalizeTelefuncError } from "../../../../lib/app-error";
import { onTestS3Connection } from "../testS3Connection.telefunc";
import type { S3ConfigInput } from "../../../../modules/media/types";
const props = defineProps<{
config: {
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
bucketName: string;
region: string;
publicDomain: string | null;
pathPrefix: string | null;
cacheControl: string;
} | null;
saving: boolean;
error: string;
}>();
const emit = defineEmits<{
close: [];
save: [input: S3ConfigInput];
}>();
const form = reactive({
endpoint: props.config?.endpoint || "",
accessKeyId: props.config?.accessKeyId || "",
secretAccessKey: props.config?.secretAccessKey || "",
bucketName: props.config?.bucketName || "",
region: props.config?.region || "auto",
publicDomain: props.config?.publicDomain || "",
pathPrefix: props.config?.pathPrefix || "",
cacheControl: props.config?.cacheControl || "public, max-age=31536000, immutable",
});
function handleSave() {
emit("save", {
endpoint: form.endpoint,
accessKeyId: form.accessKeyId,
secretAccessKey: form.secretAccessKey,
bucketName: form.bucketName,
region: form.region,
publicDomain: form.publicDomain || undefined,
pathPrefix: form.pathPrefix || undefined,
cacheControl: form.cacheControl,
});
}
const testing = ref(false);
const testResult = ref<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
testing.value = true;
testResult.value = null;
try {
const result = await onTestS3Connection({
endpoint: form.endpoint,
accessKeyId: form.accessKeyId,
secretAccessKey: form.secretAccessKey,
bucketName: form.bucketName,
region: form.region,
publicDomain: form.publicDomain || undefined,
pathPrefix: form.pathPrefix || undefined,
cacheControl: form.cacheControl,
});
testResult.value = result;
} catch (error) {
testResult.value = { ok: false, message: normalizeTelefuncError(error, "测试失败") };
} finally {
testing.value = false;
}
}
</script>

View File

@@ -0,0 +1,7 @@
import { assertAdminAccess } from "../../../modules/auth/service";
import { deleteMedia } from "../../../modules/media/service";
export async function onDeleteMedia(mediaId: number) {
assertAdminAccess();
return deleteMedia(mediaId);
}

View File

@@ -0,0 +1,8 @@
import { assertAdminAccess } from "../../../modules/auth/service";
import { listMedia } from "../../../modules/media/service";
import type { MediaListFilters } from "../../../modules/media/types";
export async function onGetMediaList(filters: MediaListFilters) {
assertAdminAccess();
return listMedia(filters);
}

View File

@@ -0,0 +1,7 @@
import { assertAdminAccess } from "../../../modules/auth/service";
import { getS3Config } from "../../../modules/media/service";
export async function onGetS3Config() {
assertAdminAccess();
return getS3Config();
}

View File

@@ -0,0 +1,8 @@
import { assertAdminAccess } from "../../../modules/auth/service";
import { saveS3Config } from "../../../modules/media/service";
import type { S3ConfigInput } from "../../../modules/media/types";
export async function onSaveS3Config(input: S3ConfigInput) {
assertAdminAccess();
return saveS3Config(input);
}

View File

@@ -0,0 +1,8 @@
import type { S3ConfigInput } from "../../../modules/media/types";
import { assertAdminAccess } from "../../../modules/auth/service";
import { testS3Connection } from "../../../modules/media/service";
export async function onTestS3Connection(input?: S3ConfigInput) {
assertAdminAccess();
return testS3Connection(input);
}

View File

@@ -0,0 +1,46 @@
-- CreateTable: S3 configuration for media storage
CREATE TABLE "S3Config" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"endpoint" TEXT NOT NULL,
"accessKeyId" TEXT NOT NULL,
"secretAccessKey" TEXT NOT NULL,
"bucketName" TEXT NOT NULL,
"region" TEXT NOT NULL DEFAULT 'auto',
"publicDomain" TEXT,
"pathPrefix" TEXT,
"cacheControl" TEXT NOT NULL DEFAULT 'public, max-age=31536000, immutable',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable: Media files uploaded to S3
CREATE TABLE "Media" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"originalName" TEXT NOT NULL,
"storedName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"fileKey" TEXT NOT NULL,
"url" TEXT NOT NULL,
"thumbnailUrl" TEXT,
"path" TEXT,
"metadata" TEXT,
"uploadedBy" INTEGER NOT NULL,
"uploadedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Media_storedName_key" ON "Media"("storedName");
-- CreateIndex
CREATE UNIQUE INDEX "Media_fileKey_key" ON "Media"("fileKey");
-- CreateIndex
CREATE INDEX "Media_mimeType_idx" ON "Media"("mimeType");
-- CreateIndex
CREATE INDEX "Media_uploadedAt_idx" ON "Media"("uploadedAt");
-- CreateIndex
CREATE INDEX "Media_path_idx" ON "Media"("path");

View File

@@ -324,3 +324,37 @@ model AdminOperationLog {
@@index([adminId, createdAt])
}
model S3Config {
id Int @id @default(autoincrement())
endpoint String
accessKeyId String
secretAccessKey String
bucketName String
region String @default("auto")
publicDomain String?
pathPrefix String?
cacheControl String @default("public, max-age=31536000, immutable")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Media {
id Int @id @default(autoincrement())
originalName String
storedName String @unique
mimeType String
fileSize Int
fileKey String @unique
url String
thumbnailUrl String?
path String?
metadata String?
uploadedBy Int
uploadedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([mimeType])
@@index([uploadedAt])
@@index([path])
}

View File

@@ -102,7 +102,7 @@ async function findAdminByCredentials(prisma: PrismaClient, username: string, pa
};
}
function createAuthjsConfig(prisma: PrismaClient) {
export function createAuthjsConfig(prisma: PrismaClient) {
return {
basePath: "/api/auth",
trustHost: true,

View File

@@ -6,6 +6,7 @@ import { registerAlipayRoutes } from "./payment-alipay";
import { registerStripeRoutes } from "./payment-stripe";
import { registerRobotsRoutes } from "./robots";
import { registerSitemapRoutes } from "./sitemap";
import { registerMediaRoutes } from "./media";
// 集中注册所有 `/api/*` 路由,避免入口文件散落多个 register 调用。
export function registerApiRoutes(app: Hono) {
@@ -16,5 +17,6 @@ export function registerApiRoutes(app: Hono) {
registerStripeRoutes(app);
registerRobotsRoutes(app);
registerSitemapRoutes(app);
registerMediaRoutes(app);
}

136
server/routes/media.ts Normal file
View File

@@ -0,0 +1,136 @@
import type { Hono } from "hono";
import { getPrismaForD1 } from "../prisma-factory";
import { getSession, createAuthjsConfig } from "../authjs-handler";
import { handleUpload } from "../../modules/media/service";
import { getMediaByKey } from "../../modules/media/repository";
import { getS3ConfigRecord } from "../../modules/media/repository";
import { createS3ClientFromConfig } from "../../lib/s3/client";
import { toErrorResponsePayload } from "../../lib/app-error";
import { logger } from "../../lib/logger";
export function registerMediaRoutes(app: Hono) {
/**
* POST /api/media/upload
* File upload endpoint using multipart form data
* Requires admin authentication (checked via Auth.js session)
*/
app.post("/api/media/upload", async (c) => {
try {
const database = (c.env as { DB?: D1Database } | undefined)?.DB;
if (!database) {
return c.json({ message: "数据库绑定缺失", code: "D1_BINDING_MISSING" }, 500);
}
const prisma = getPrismaForD1(database);
// Verify admin session using Auth.js
const authConfig = createAuthjsConfig(prisma);
const session = await getSession(c.req.raw, authConfig);
if (!session?.user || (session.user as any).role !== "admin") {
return c.json({ message: "请先登录管理员账号", code: "UNAUTHORIZED" }, 401);
}
const adminId = Number(session.user.id);
if (!Number.isFinite(adminId) || adminId <= 0) {
return c.json({ message: "会话无效", code: "INVALID_SESSION" }, 401);
}
// Parse multipart form data
const formData = await c.req.formData();
const file = formData.get("file") as File | null;
const path = (formData.get("path") as string) || undefined;
if (!file) {
return c.json({ message: "请选择要上传的文件", code: "FILE_REQUIRED" }, 400);
}
// Upload file
const media = await handleUpload(prisma, adminId, file, path);
return c.json({
success: true,
message: "文件上传成功",
data: media,
});
} catch (error) {
logger.error(error instanceof Error ? error : new Error(String(error)), {
event: "media.upload.failed",
});
const payload = toErrorResponsePayload(error);
return c.json(payload, payload.statusCode as any);
}
});
/**
* GET /api/media/proxy/*
* Proxy media file from S3 through Worker domain
* Adds Cache-Control headers for Cloudflare caching
* This endpoint is public - no auth required (files are served publicly)
*/
app.get("/api/media/proxy/*", async (c) => {
try {
const database = (c.env as { DB?: D1Database } | undefined)?.DB;
if (!database) {
return c.json({ message: "数据库绑定缺失", code: "D1_BINDING_MISSING" }, 500);
}
const prisma = getPrismaForD1(database);
// Extract the key from the URL path
const fullPath = c.req.path;
const keyPrefix = "/api/media/proxy/";
const fileKey = fullPath.substring(fullPath.indexOf(keyPrefix) + keyPrefix.length);
if (!fileKey) {
return c.json({ message: "文件路径无效", code: "INVALID_KEY" }, 400);
}
// Look up the media record
const media = await getMediaByKey(prisma, fileKey);
if (!media) {
return c.json({ message: "文件不存在", code: "NOT_FOUND" }, 404);
}
// Get S3 config
const s3Config = await getS3ConfigRecord(prisma);
if (!s3Config) {
return c.json({ message: "S3 配置缺失", code: "S3_CONFIG_MISSING" }, 500);
}
// Fetch from S3
const s3Client = createS3ClientFromConfig(s3Config);
const s3Response = await s3Client.getObject(fileKey);
if (!s3Response.ok) {
if (s3Response.status === 404) {
return c.json({ message: "文件不存在于存储中", code: "S3_NOT_FOUND" }, 404);
}
return c.json({ message: "获取文件失败", code: "S3_ERROR" }, 502);
}
// Build response with Cache-Control header
const headers = new Headers();
headers.set("Content-Type", media.mimeType);
headers.set("Cache-Control", s3Config.cacheControl || "public, max-age=31536000, immutable");
headers.set("Content-Disposition", `inline; filename="${encodeURIComponent(media.originalName)}"`);
const contentLength = s3Response.headers.get("Content-Length");
if (contentLength) {
headers.set("Content-Length", contentLength);
}
const etag = s3Response.headers.get("ETag");
if (etag) {
headers.set("ETag", etag);
}
return new Response(s3Response.body, {
status: 200,
headers,
});
} catch (error) {
logger.error(error instanceof Error ? error : new Error(String(error)), {
event: "media.proxy.failed",
});
return c.json({ message: "获取文件失败", code: "INTERNAL_ERROR" }, 500);
}
});
}