review page, first
This commit is contained in:
344
src/App.vue
344
src/App.vue
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { ref, watch, onMounted, computed, onUnmounted } from "vue";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Document, VideoPlay, CircleCheck, Moon, Sunny } from '@element-plus/icons-vue';
|
||||
import { Document, VideoPlay, CircleCheck, Moon, Sunny, Delete, UploadFilled } from '@element-plus/icons-vue';
|
||||
import { listen, UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
// Navigation
|
||||
const activeMenu = ref("preparation");
|
||||
@@ -12,7 +13,6 @@ const activeMenu = ref("preparation");
|
||||
const isDark = ref(false);
|
||||
|
||||
const toggleTheme = (val: string | number | boolean) => {
|
||||
// val comes from el-switch change event which is boolean | string | number
|
||||
const isDarkMode = val === true;
|
||||
isDark.value = isDarkMode;
|
||||
if (isDarkMode) {
|
||||
@@ -24,7 +24,26 @@ const toggleTheme = (val: string | number | boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Data
|
||||
const workingDir = ref("");
|
||||
const templateDir = ref("");
|
||||
const selectedDate = ref<Date | null>(new Date());
|
||||
const currentDir = ref("");
|
||||
const videoNamePrefix = ref("");
|
||||
const isCurrentDirValid = ref(false);
|
||||
|
||||
// Review Data
|
||||
const importedFiles = ref<string[]>([]);
|
||||
const videoNameInput = ref("");
|
||||
const historyList = ref<string[]>([]);
|
||||
const historyMap = ref<Record<string, string[]>>({});
|
||||
const currentHistoryKey = ref(""); // Derived from dropped files relative to currentDir
|
||||
|
||||
// Lifecycle
|
||||
let unlistenDrop: UnlistenFn | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
// Theme init
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
isDark.value = true;
|
||||
@@ -34,23 +53,35 @@ onMounted(async () => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Config init
|
||||
await loadConfig();
|
||||
|
||||
// Validate initial dir
|
||||
validateCurrentDir();
|
||||
|
||||
// Load History
|
||||
loadHistory();
|
||||
|
||||
// Listen for file drops
|
||||
unlistenDrop = await listen<{ paths: string[] }>('tauri://drag-drop', (event) => {
|
||||
if (activeMenu.value === 'review' && !isReviewDisabled.value) {
|
||||
handleFileDrop(event.payload.paths);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Preparation Data
|
||||
const workingDir = ref("");
|
||||
const templateDir = ref("");
|
||||
const selectedDate = ref<Date | null>(new Date());
|
||||
const currentDir = ref("");
|
||||
const videoNamePrefix = ref("");
|
||||
onUnmounted(() => {
|
||||
if (unlistenDrop) {
|
||||
unlistenDrop();
|
||||
}
|
||||
});
|
||||
|
||||
// Configuration Types
|
||||
// Config & Validation
|
||||
interface AppConfig {
|
||||
working_dir: string;
|
||||
template_dir: string;
|
||||
}
|
||||
|
||||
// Load/Save Config Logic
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await invoke<AppConfig>('load_config');
|
||||
@@ -74,16 +105,149 @@ const saveConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers for Auto-Save
|
||||
watch(workingDir, () => {
|
||||
saveConfig();
|
||||
const validateCurrentDir = async () => {
|
||||
if (!currentDir.value) {
|
||||
isCurrentDirValid.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const exists = await invoke<boolean>('check_dir_exists', { path: currentDir.value });
|
||||
isCurrentDirValid.value = exists;
|
||||
} catch (e) {
|
||||
isCurrentDirValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([workingDir, templateDir], saveConfig);
|
||||
watch(currentDir, validateCurrentDir);
|
||||
|
||||
// History Logic
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const history = await invoke<Record<string, string[]>>('load_history');
|
||||
historyMap.value = history || {};
|
||||
} catch (e) {
|
||||
console.error("Failed to load history", e);
|
||||
}
|
||||
}
|
||||
|
||||
const updateHistoryList = () => {
|
||||
if (!currentHistoryKey.value) {
|
||||
historyList.value = [];
|
||||
return;
|
||||
}
|
||||
historyList.value = historyMap.value[currentHistoryKey.value] || [];
|
||||
}
|
||||
|
||||
const handleFileDrop = (paths: string[]) => {
|
||||
// Filter for common video extensions if needed, or just accept all
|
||||
// For now, accept all but assume they are videos
|
||||
importedFiles.value = paths;
|
||||
|
||||
if (paths.length > 0 && currentDir.value) {
|
||||
// Calculate Key: Path relative to Current Dir
|
||||
// Logic: Take first file's parent dir. Remove currentDir prefix.
|
||||
// Example: Current=C:\A, File=C:\A\B\vid.mp4. Parent=C:\A\B. Key=B.
|
||||
|
||||
// Normalize paths for comparison (remove trailing slashes, handle win/unix separators)
|
||||
// Simple string manipulation for now.
|
||||
const firstFile = paths[0];
|
||||
// We need the parent directory of the file
|
||||
// Since we don't have node 'path' module easily, we do simple string parsing
|
||||
const separator = firstFile.includes('\\') ? '\\' : '/';
|
||||
const lastSeparatorIndex = firstFile.lastIndexOf(separator);
|
||||
const parentDir = firstFile.substring(0, lastSeparatorIndex);
|
||||
|
||||
if (parentDir.startsWith(currentDir.value)) {
|
||||
let relative = parentDir.substring(currentDir.value.length);
|
||||
// Remove leading separator if exists
|
||||
if (relative.startsWith(separator)) {
|
||||
relative = relative.substring(1);
|
||||
}
|
||||
currentHistoryKey.value = relative;
|
||||
} else {
|
||||
// File is outside current dir?
|
||||
// Requirement says: "video file path minus current directory prefix"
|
||||
// If outside, maybe use full path or just handle gracefully
|
||||
currentHistoryKey.value = "";
|
||||
}
|
||||
updateHistoryList();
|
||||
}
|
||||
}
|
||||
|
||||
const deleteHistoryItem = async (val: string) => {
|
||||
if (!currentHistoryKey.value) return;
|
||||
try {
|
||||
const newMap = await invoke<Record<string, string[]>>('remove_history_item', {
|
||||
key: currentHistoryKey.value,
|
||||
value: val
|
||||
});
|
||||
historyMap.value = newMap;
|
||||
updateHistoryList();
|
||||
} catch (e) {
|
||||
ElMessage.error("删除失败: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!videoNameInput.value) {
|
||||
ElMessage.warning("请输入名称");
|
||||
return;
|
||||
}
|
||||
if (importedFiles.value.length === 0) {
|
||||
ElMessage.warning("请先拖入视频文件");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to history
|
||||
if (currentHistoryKey.value) {
|
||||
try {
|
||||
const newMap = await invoke<Record<string, string[]>>('save_history_item', {
|
||||
key: currentHistoryKey.value,
|
||||
value: videoNameInput.value
|
||||
});
|
||||
historyMap.value = newMap;
|
||||
updateHistoryList();
|
||||
} catch (e) {
|
||||
console.error("History save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Rename Files
|
||||
try {
|
||||
const msg = await invoke<string>('rename_videos', {
|
||||
files: importedFiles.value,
|
||||
prefix: videoNamePrefix.value,
|
||||
baseName: videoNameInput.value
|
||||
});
|
||||
ElMessage.success(msg);
|
||||
// Clear imported files after success? Or keep them?
|
||||
// Usually clearing is better feedback that "job done".
|
||||
importedFiles.value = [];
|
||||
} catch (e) {
|
||||
ElMessage.error("重命名失败: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
const useHistoryItem = (val: string) => {
|
||||
videoNameInput.value = val;
|
||||
}
|
||||
|
||||
|
||||
// Computed
|
||||
const isReviewDisabled = computed(() => {
|
||||
return !isCurrentDirValid.value || !videoNamePrefix.value;
|
||||
});
|
||||
|
||||
watch(templateDir, () => {
|
||||
saveConfig();
|
||||
const reviewWarningMessage = computed(() => {
|
||||
if (!currentDir.value) return "请先设置当前目录";
|
||||
if (!isCurrentDirValid.value) return "当前目录不存在,请检查";
|
||||
if (!videoNamePrefix.value) return "文件前缀为空";
|
||||
return "";
|
||||
});
|
||||
|
||||
// Actions
|
||||
|
||||
// Actions (Preparation)
|
||||
const selectWorkingDir = async () => {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
@@ -133,33 +297,25 @@ const handleCopy = async () => {
|
||||
});
|
||||
|
||||
ElMessage.success("目录拷贝并重命名成功!");
|
||||
|
||||
// Update Section 2
|
||||
currentDir.value = newPath;
|
||||
// Auto-update prefix is handled by watcher
|
||||
} catch (error) {
|
||||
ElMessage.error(`错误: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Reactivity for Current Directory
|
||||
// Reactivity for Current Directory Prefix
|
||||
watch(currentDir, (newPath) => {
|
||||
if (!newPath) return;
|
||||
|
||||
// Check if path ends with "ddmm视频"
|
||||
// Robust split for Windows/Unix paths
|
||||
const dirName = newPath.split(/[/\\]/).pop();
|
||||
|
||||
if (!dirName) return;
|
||||
|
||||
const regex = /^(\d{2})(\d{2})视频$/; // matches mmdd视频
|
||||
const regex = /^(\d{2})(\d{2})视频$/;
|
||||
const match = dirName.match(regex);
|
||||
|
||||
if (match) {
|
||||
const mm = match[1];
|
||||
const dd = match[2];
|
||||
const year = new Date().getFullYear();
|
||||
// Format: AI-yyyymmdd-
|
||||
videoNamePrefix.value = `AI-${year}${mm}${dd}-`;
|
||||
}
|
||||
});
|
||||
@@ -201,6 +357,7 @@ watch(currentDir, (newPath) => {
|
||||
</el-aside>
|
||||
|
||||
<el-main>
|
||||
<!-- Preparation Page -->
|
||||
<div v-if="activeMenu === 'preparation'">
|
||||
<h2>准备工作</h2>
|
||||
|
||||
@@ -270,9 +427,58 @@ watch(currentDir, (newPath) => {
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Review Page -->
|
||||
<div v-else-if="activeMenu === 'review'">
|
||||
<h2>审核</h2>
|
||||
<el-empty description="功能待定" />
|
||||
|
||||
<el-alert
|
||||
v-if="isReviewDisabled"
|
||||
:title="reviewWarningMessage"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px"
|
||||
/>
|
||||
|
||||
<div :class="{ 'disabled-area': isReviewDisabled }">
|
||||
<!-- Drag Area -->
|
||||
<div class="drag-area">
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将视频文件拖拽到此处
|
||||
</div>
|
||||
<div class="file-count" v-if="importedFiles.length > 0">
|
||||
已导入 {{ importedFiles.length }} 个文件
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- Action Area -->
|
||||
<div class="action-area">
|
||||
<el-input v-model="videoNameInput" placeholder="输入视频名称 (例如: 文本)" class="name-input">
|
||||
<template #prepend>{{ videoNamePrefix }}</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleRename" :disabled="isReviewDisabled || importedFiles.length === 0">命名</el-button>
|
||||
</div>
|
||||
|
||||
<!-- History List -->
|
||||
<div class="history-area" v-if="historyList.length > 0">
|
||||
<h4>历史记录 ({{ currentHistoryKey || '未关联目录' }})</h4>
|
||||
<el-scrollbar max-height="300px">
|
||||
<ul class="history-list">
|
||||
<li v-for="item in historyList" :key="item" @click="useHistoryItem(item)">
|
||||
<span class="history-text">{{ item }}</span>
|
||||
<el-icon class="delete-icon" @click.stop="deleteHistoryItem(item)"><Delete /></el-icon>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<div v-else-if="currentHistoryKey" class="no-history">
|
||||
<span class="text-secondary">该目录下暂无历史记录</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeMenu === 'check'">
|
||||
@@ -309,7 +515,85 @@ body {
|
||||
border-top: 1px solid var(--el-border-color);
|
||||
}
|
||||
.box-card {
|
||||
max-width: 800px;
|
||||
/* max-width: 800px; */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
/* Review Page Styles */
|
||||
.disabled-area {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.drag-area {
|
||||
border: 2px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
}
|
||||
.el-icon--upload {
|
||||
font-size: 67px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-bottom: 16px;
|
||||
line-height: 50px;
|
||||
}
|
||||
.el-upload__text {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.file-count {
|
||||
margin-top: 10px;
|
||||
color: var(--el-color-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
.action-area {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.history-area h4 {
|
||||
margin-bottom: 10px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.history-list li {
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
.history-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.history-list li:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
.history-text {
|
||||
flex: 1;
|
||||
}
|
||||
.delete-icon {
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.delete-icon:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.no-history {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user