11
This commit is contained in:
@@ -38,7 +38,7 @@ const mainChildren = [
|
||||
|
||||
name: "material",
|
||||
|
||||
component: placeholder,
|
||||
component: () => import("../views/MaterialView.vue"),
|
||||
|
||||
meta: { title: "素材" },
|
||||
|
||||
@@ -56,65 +56,65 @@ const mainChildren = [
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
// {
|
||||
|
||||
path: "compose",
|
||||
// path: "compose",
|
||||
|
||||
name: "compose",
|
||||
// name: "compose",
|
||||
|
||||
component: placeholder,
|
||||
// component: placeholder,
|
||||
|
||||
meta: { title: "合成" },
|
||||
// meta: { title: "合成" },
|
||||
|
||||
},
|
||||
// },
|
||||
|
||||
{
|
||||
// {
|
||||
|
||||
path: "extract",
|
||||
// path: "extract",
|
||||
|
||||
name: "extract",
|
||||
// name: "extract",
|
||||
|
||||
component: placeholder,
|
||||
// component: placeholder,
|
||||
|
||||
meta: { title: "提取" },
|
||||
// meta: { title: "提取" },
|
||||
|
||||
},
|
||||
// },
|
||||
|
||||
{
|
||||
// {
|
||||
|
||||
path: "design",
|
||||
// path: "design",
|
||||
|
||||
name: "design",
|
||||
// name: "design",
|
||||
|
||||
component: placeholder,
|
||||
// component: placeholder,
|
||||
|
||||
meta: { title: "设计" },
|
||||
// meta: { title: "设计" },
|
||||
|
||||
},
|
||||
// },
|
||||
|
||||
{
|
||||
// {
|
||||
|
||||
path: "model",
|
||||
// path: "model",
|
||||
|
||||
name: "model",
|
||||
// name: "model",
|
||||
|
||||
component: placeholder,
|
||||
// component: placeholder,
|
||||
|
||||
meta: { title: "模型" },
|
||||
// meta: { title: "模型" },
|
||||
|
||||
},
|
||||
// },
|
||||
|
||||
{
|
||||
// {
|
||||
|
||||
path: "help",
|
||||
// path: "help",
|
||||
|
||||
name: "help",
|
||||
// name: "help",
|
||||
|
||||
component: placeholder,
|
||||
// component: placeholder,
|
||||
|
||||
meta: { title: "帮助" },
|
||||
// meta: { title: "帮助" },
|
||||
|
||||
},
|
||||
// },
|
||||
|
||||
{
|
||||
|
||||
|
||||
49
src/services/materialDb.js
Normal file
49
src/services/materialDb.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("素材管理需在 Tauri 桌面端使用");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ id: number, name: string, path: string, type: 'image' | 'video', info: object, createdAt: number, updatedAt: number }} MaterialRecord
|
||||
*/
|
||||
|
||||
/** @returns {Promise<MaterialRecord[]>} */
|
||||
export async function listMaterials() {
|
||||
ensureTauri();
|
||||
return invoke("list_materials");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function updateMaterialName(id, name) {
|
||||
ensureTauri();
|
||||
return invoke("update_material_name", { id, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
export async function deleteMaterial(id) {
|
||||
ensureTauri();
|
||||
return invoke("delete_material", { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} sourcePaths
|
||||
* @returns {Promise<MaterialRecord[]>}
|
||||
*/
|
||||
export async function importMaterialFiles(sourcePaths) {
|
||||
ensureTauri();
|
||||
return invoke("import_material_files", { sourcePaths });
|
||||
}
|
||||
|
||||
export const MATERIAL_UPDATED_EVENT = "video-material-updated";
|
||||
|
||||
export function dispatchMaterialUpdated() {
|
||||
window.dispatchEvent(new CustomEvent(MATERIAL_UPDATED_EVENT));
|
||||
}
|
||||
50
src/services/materialMedia.js
Normal file
50
src/services/materialMedia.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
|
||||
const MEDIA_EXTENSIONS = [
|
||||
"mp4",
|
||||
"mov",
|
||||
"avi",
|
||||
"mkv",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
];
|
||||
|
||||
/**
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function pickMaterialFiles() {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端选择素材文件");
|
||||
}
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
title: "选择素材文件",
|
||||
filters: [
|
||||
{
|
||||
name: "媒体文件",
|
||||
extensions: MEDIA_EXTENSIONS,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return [];
|
||||
if (Array.isArray(selected)) {
|
||||
return selected.map(String).filter(Boolean);
|
||||
}
|
||||
return [String(selected)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function localMediaToPlayableUrl(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端预览本地素材");
|
||||
}
|
||||
return convertFileSrc(filePath);
|
||||
}
|
||||
76
src/stores/material.js
Normal file
76
src/stores/material.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore, acceptHMRUpdate } from "pinia";
|
||||
import {
|
||||
listMaterials,
|
||||
updateMaterialName,
|
||||
deleteMaterial,
|
||||
importMaterialFiles,
|
||||
dispatchMaterialUpdated,
|
||||
} from "../services/materialDb.js";
|
||||
|
||||
export const useMaterialStore = defineStore("material", {
|
||||
state: () => ({
|
||||
items: [],
|
||||
loading: false,
|
||||
uploading: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async refresh() {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.items = await listMaterials();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string[]} sourcePaths
|
||||
*/
|
||||
async uploadFiles(sourcePaths) {
|
||||
if (!sourcePaths.length) return { ok: true };
|
||||
this.uploading = true;
|
||||
try {
|
||||
await importMaterialFiles(sourcePaths);
|
||||
await this.refresh();
|
||||
dispatchMaterialUpdated();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: msg || "上传失败" };
|
||||
} finally {
|
||||
this.uploading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
* @param {string} name
|
||||
*/
|
||||
async renameItem(id, name) {
|
||||
const trimmed = String(name || "").trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, message: "名称不能为空" };
|
||||
}
|
||||
try {
|
||||
await updateMaterialName(id, trimmed);
|
||||
await this.refresh();
|
||||
dispatchMaterialUpdated();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: msg || "重命名失败" };
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(item) {
|
||||
await deleteMaterial(item.id);
|
||||
await this.refresh();
|
||||
dispatchMaterialUpdated();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useMaterialStore, import.meta.hot));
|
||||
}
|
||||
236
src/views/MaterialView.vue
Normal file
236
src/views/MaterialView.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useMaterialStore } from "../stores/material.js";
|
||||
import { pickMaterialFiles, localMediaToPlayableUrl } from "../services/materialMedia.js";
|
||||
|
||||
const materialStore = useMaterialStore();
|
||||
const { items, loading, uploading } = storeToRefs(materialStore);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const editingId = ref(null);
|
||||
const editingName = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
materialStore.refresh();
|
||||
});
|
||||
|
||||
function showFeedback(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
function mediaSrc(item) {
|
||||
if (!item?.path) return "";
|
||||
try {
|
||||
return localMediaToPlayableUrl(item.path);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(item) {
|
||||
return item?.type === "image" ? "图片" : "视频";
|
||||
}
|
||||
|
||||
function typeSeverity(item) {
|
||||
return item?.type === "image" ? "info" : "success";
|
||||
}
|
||||
|
||||
function startRename(item) {
|
||||
editingId.value = item.id;
|
||||
editingName.value = item.name;
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingId.value = null;
|
||||
editingName.value = "";
|
||||
}
|
||||
|
||||
async function commitRename(item) {
|
||||
const result = await materialStore.renameItem(item.id, editingName.value);
|
||||
if (!result.ok) {
|
||||
showFeedback("error", result.message || "重命名失败");
|
||||
return;
|
||||
}
|
||||
cancelRename();
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
await materialStore.refresh();
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
try {
|
||||
const paths = await pickMaterialFiles();
|
||||
if (!paths.length) return;
|
||||
const result = await materialStore.uploadFiles(paths);
|
||||
if (!result.ok) {
|
||||
showFeedback("error", result.message || "上传失败");
|
||||
return;
|
||||
}
|
||||
showFeedback("success", "素材已上传");
|
||||
} catch (err) {
|
||||
showFeedback(
|
||||
"error",
|
||||
err instanceof Error ? err.message : String(err || "选择文件失败"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(item) {
|
||||
if (!window.confirm(`确认删除素材「${item.name}」?`)) return;
|
||||
await materialStore.removeItem(item);
|
||||
showFeedback("success", "已删除");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="video-material-page custom-scrollbar h-full overflow-y-auto p-5">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div class="flex flex-grow items-end gap-3">
|
||||
<h1 class="material-page-title text-3xl font-bold text-slate-100">素材库</h1>
|
||||
<p class="material-page-subtitle text-sm text-slate-500">
|
||||
管理画中画和字幕逐行切换素材
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
label="刷新"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-refresh"
|
||||
:loading="loading"
|
||||
@click="onRefresh"
|
||||
/>
|
||||
<Button
|
||||
label="上传素材"
|
||||
size="small"
|
||||
icon="pi pi-plus"
|
||||
:loading="uploading"
|
||||
@click="onUpload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="material-page-tip mb-4 text-sm text-slate-500">
|
||||
上传后的素材会保存到本地素材库,画中画字幕模式可直接逐行选择,不需要每次重新选文件。
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mb-3 text-sm"
|
||||
:class="{
|
||||
'text-red-400': feedback.severity === 'error',
|
||||
'text-emerald-400': feedback.severity === 'success',
|
||||
}"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<p v-if="loading && items.length === 0" class="py-12 text-center text-sm text-slate-500">
|
||||
加载中...
|
||||
</p>
|
||||
<p
|
||||
v-else-if="items.length === 0"
|
||||
class="rounded-xl border border-white/10 bg-white/5 py-16 text-center text-sm text-slate-500"
|
||||
>
|
||||
暂无素材,点击「上传素材」添加图片或视频
|
||||
</p>
|
||||
|
||||
<div v-else class="-mx-2 flex flex-wrap">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="w-full flex-shrink-0 p-2 sm:w-1/2 lg:w-1/3 xl:w-1/4"
|
||||
>
|
||||
<div
|
||||
class="material-card rounded-xl border border-white/10 bg-white/5 p-4 shadow-sm transition-shadow hover:border-cyan-500/30 hover:shadow-md"
|
||||
>
|
||||
<div class="mb-3 flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-grow">
|
||||
<div
|
||||
v-if="editingId !== item.id"
|
||||
class="material-name inline-flex max-w-full items-center rounded-full bg-blue-500/15 px-2 py-1 leading-8"
|
||||
>
|
||||
<span
|
||||
class="material-title-text flex-grow cursor-pointer truncate text-sm font-medium text-slate-100"
|
||||
:title="item.name"
|
||||
@click="startRename(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="material-edit-trigger ml-1 shrink-0 text-slate-500 hover:text-blue-400"
|
||||
aria-label="重命名"
|
||||
@click="startRename(item)"
|
||||
>
|
||||
<i class="pi pi-pencil text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex gap-1">
|
||||
<InputText v-model="editingName" size="small" class="flex-1" />
|
||||
<Button
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
severity="success"
|
||||
aria-label="确认"
|
||||
@click="commitRename(item)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
aria-label="取消"
|
||||
@click="cancelRename"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Tag :value="typeLabel(item)" :severity="typeSeverity(item)" class="shrink-0" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div
|
||||
class="material-preview-shell flex h-48 items-center justify-center overflow-hidden rounded-lg bg-black p-2"
|
||||
>
|
||||
<img
|
||||
v-if="item.type === 'image' && mediaSrc(item)"
|
||||
:src="mediaSrc(item)"
|
||||
:alt="item.name"
|
||||
class="max-h-full max-w-full rounded object-contain"
|
||||
/>
|
||||
<video
|
||||
v-else-if="item.type !== 'image' && mediaSrc(item)"
|
||||
:src="mediaSrc(item)"
|
||||
controls
|
||||
class="max-h-full max-w-full rounded object-contain"
|
||||
/>
|
||||
<p v-else class="text-xs text-slate-500">无法预览</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="material-path mb-3 truncate text-xs text-slate-500"
|
||||
:title="item.path"
|
||||
>
|
||||
{{ item.path }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
label="删除"
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
icon="pi pi-trash"
|
||||
@click="onDelete(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user