review page, first

This commit is contained in:
Julian Freeman
2025-12-07 19:41:25 -04:00
parent b9a41342a5
commit 4fab0b813f
3 changed files with 489 additions and 33 deletions

View File

@@ -3,6 +3,9 @@ use fs_extra::dir::CopyOptions;
use tauri::Manager; use tauri::Manager;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io;
// Define the configuration structure // Define the configuration structure
#[derive(Serialize, Deserialize, Default, Debug)] #[derive(Serialize, Deserialize, Default, Debug)]
@@ -11,6 +14,10 @@ pub struct AppConfig {
pub template_dir: String, pub template_dir: String,
} }
// History Structure
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
pub struct HistoryMap(HashMap<String, Vec<String>>);
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command] #[tauri::command]
fn greet(name: &str) -> String { fn greet(name: &str) -> String {
@@ -74,13 +81,178 @@ fn load_config(app_handle: tauri::AppHandle) -> Result<AppConfig, String> {
Ok(config) Ok(config)
} }
// --- History Commands ---
fn get_history_path(app_handle: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let config_dir = app_handle.path().app_config_dir().map_err(|e| e.to_string())?;
if !config_dir.exists() {
fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?;
}
Ok(config_dir.join("history.json"))
}
#[tauri::command]
fn load_history(app_handle: tauri::AppHandle) -> Result<HistoryMap, String> {
let path = get_history_path(&app_handle)?;
if !path.exists() {
return Ok(HistoryMap::default());
}
let json = fs::read_to_string(path).map_err(|e| e.to_string())?;
let history: HistoryMap = serde_json::from_str(&json).map_err(|e| e.to_string())?;
Ok(history)
}
#[tauri::command]
fn save_history_item(app_handle: tauri::AppHandle, key: String, value: String) -> Result<HistoryMap, String> {
let path = get_history_path(&app_handle)?;
let mut history = if path.exists() {
let json = fs::read_to_string(&path).map_err(|e| e.to_string())?;
serde_json::from_str(&json).unwrap_or_default()
} else {
HistoryMap::default()
};
let list = history.0.entry(key).or_insert_with(Vec::new);
if !list.contains(&value) {
list.push(value);
}
let json = serde_json::to_string_pretty(&history).map_err(|e| e.to_string())?;
fs::write(path, json).map_err(|e| e.to_string())?;
Ok(history)
}
#[tauri::command]
fn remove_history_item(app_handle: tauri::AppHandle, key: String, value: String) -> Result<HistoryMap, String> {
let path = get_history_path(&app_handle)?;
if !path.exists() {
return Ok(HistoryMap::default());
}
let json = fs::read_to_string(&path).map_err(|e| e.to_string())?;
let mut history: HistoryMap = serde_json::from_str(&json).map_err(|e| e.to_string())?;
if let Some(list) = history.0.get_mut(&key) {
list.retain(|x| x != &value);
if list.is_empty() {
history.0.remove(&key);
}
}
let json = serde_json::to_string_pretty(&history).map_err(|e| e.to_string())?;
fs::write(path, json).map_err(|e| e.to_string())?;
Ok(history)
}
#[tauri::command]
fn check_dir_exists(path: String) -> bool {
Path::new(&path).exists()
}
// --- Renaming Logic ---
#[tauri::command]
fn rename_videos(files: Vec<String>, prefix: String, base_name: String) -> Result<String, String> {
if files.is_empty() {
return Err("No files provided".to_string());
}
// 1. Group files by parent directory to ensure index uniqueness per directory
let mut files_by_dir: HashMap<std::path::PathBuf, Vec<std::path::PathBuf>> = HashMap::new();
for file_str in files {
let path = std::path::PathBuf::from(file_str);
if path.exists() && path.is_file() {
if let Some(parent) = path.parent() {
files_by_dir.entry(parent.to_path_buf()).or_default().push(path);
}
}
}
let mut renamed_count = 0;
// 2. Process each directory
for (dir, file_list) in files_by_dir {
// Find existing indices
let mut occupied_indices = HashSet::new();
let read_dir = fs::read_dir(&dir).map_err(|e| e.to_string())?;
for entry in read_dir {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_file() {
if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
// Check if file matches Pattern: Prefix + base_name + XX + .ext
// e.g., "AI-20231207-" + "Text" + "01" + ".mp4"
let name_without_ext = Path::new(file_name).file_stem().and_then(|s| s.to_str()).unwrap_or("");
let prefix_base = format!("{}{}", prefix, base_name);
if name_without_ext.starts_with(&prefix_base) {
let suffix = &name_without_ext[prefix_base.len()..];
// suffix should be digits (e.g., "01", "02")
if let Ok(index) = suffix.parse::<u32>() {
occupied_indices.insert(index);
}
}
}
}
}
// Rename files in this directory
let mut current_index = 1;
for file_path in file_list {
// Find next available index
while occupied_indices.contains(&current_index) {
current_index += 1;
}
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let new_name = if ext.is_empty() {
format!("{}{}{:02}", prefix, base_name, current_index)
} else {
format!("{}{}{:02}.{}", prefix, base_name, current_index, ext)
};
let new_path = dir.join(new_name);
// Should not happen due to index check, but safety first
if !new_path.exists() {
fs::rename(&file_path, &new_path).map_err(|e| e.to_string())?;
occupied_indices.insert(current_index);
renamed_count += 1;
} else {
// If it exists, skip to next index (rare race condition or manually named weirdly)
current_index += 1;
// Retry logic could go here, but for now let's just try next loop or fail this file
// For robustness, let's just skip this file to avoid overwrite
}
}
}
Ok(format!("Successfully renamed {} files.", renamed_count))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![greet, copy_directory, save_config, load_config]) .invoke_handler(tauri::generate_handler![
greet,
copy_directory,
save_config,
load_config,
load_history,
save_history_item,
remove_history_item,
check_dir_exists,
rename_videos
])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -13,7 +13,7 @@
"windows": [ "windows": [
{ {
"title": "review-videos", "title": "review-videos",
"width": 1050, "width": 1200,
"height": 840 "height": 840
} }
], ],

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <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 { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { ElMessage } from 'element-plus'; 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 // Navigation
const activeMenu = ref("preparation"); const activeMenu = ref("preparation");
@@ -12,7 +13,6 @@ const activeMenu = ref("preparation");
const isDark = ref(false); const isDark = ref(false);
const toggleTheme = (val: string | number | boolean) => { const toggleTheme = (val: string | number | boolean) => {
// val comes from el-switch change event which is boolean | string | number
const isDarkMode = val === true; const isDarkMode = val === true;
isDark.value = isDarkMode; isDark.value = isDarkMode;
if (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 () => { onMounted(async () => {
// Theme init
const savedTheme = localStorage.getItem('theme'); const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
isDark.value = true; isDark.value = true;
@@ -34,23 +53,35 @@ onMounted(async () => {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
} }
// Config init
await loadConfig(); 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 onUnmounted(() => {
const workingDir = ref(""); if (unlistenDrop) {
const templateDir = ref(""); unlistenDrop();
const selectedDate = ref<Date | null>(new Date()); }
const currentDir = ref(""); });
const videoNamePrefix = ref("");
// Configuration Types // Config & Validation
interface AppConfig { interface AppConfig {
working_dir: string; working_dir: string;
template_dir: string; template_dir: string;
} }
// Load/Save Config Logic
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const config = await invoke<AppConfig>('load_config'); const config = await invoke<AppConfig>('load_config');
@@ -74,16 +105,149 @@ const saveConfig = async () => {
} }
} }
// Watchers for Auto-Save const validateCurrentDir = async () => {
watch(workingDir, () => { if (!currentDir.value) {
saveConfig(); 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, () => { const reviewWarningMessage = computed(() => {
saveConfig(); if (!currentDir.value) return "请先设置当前目录";
if (!isCurrentDirValid.value) return "当前目录不存在,请检查";
if (!videoNamePrefix.value) return "文件前缀为空";
return "";
}); });
// Actions
// Actions (Preparation)
const selectWorkingDir = async () => { const selectWorkingDir = async () => {
const selected = await open({ const selected = await open({
directory: true, directory: true,
@@ -133,33 +297,25 @@ const handleCopy = async () => {
}); });
ElMessage.success("目录拷贝并重命名成功!"); ElMessage.success("目录拷贝并重命名成功!");
// Update Section 2
currentDir.value = newPath; currentDir.value = newPath;
// Auto-update prefix is handled by watcher
} catch (error) { } catch (error) {
ElMessage.error(`错误: ${error}`); ElMessage.error(`错误: ${error}`);
} }
}; };
// Reactivity for Current Directory // Reactivity for Current Directory Prefix
watch(currentDir, (newPath) => { watch(currentDir, (newPath) => {
if (!newPath) return; if (!newPath) return;
// Check if path ends with "ddmm视频"
// Robust split for Windows/Unix paths
const dirName = newPath.split(/[/\\]/).pop(); const dirName = newPath.split(/[/\\]/).pop();
if (!dirName) return; if (!dirName) return;
const regex = /^(\d{2})(\d{2})视频$/; // matches mmdd视频 const regex = /^(\d{2})(\d{2})视频$/;
const match = dirName.match(regex); const match = dirName.match(regex);
if (match) { if (match) {
const mm = match[1]; const mm = match[1];
const dd = match[2]; const dd = match[2];
const year = new Date().getFullYear(); const year = new Date().getFullYear();
// Format: AI-yyyymmdd-
videoNamePrefix.value = `AI-${year}${mm}${dd}-`; videoNamePrefix.value = `AI-${year}${mm}${dd}-`;
} }
}); });
@@ -201,6 +357,7 @@ watch(currentDir, (newPath) => {
</el-aside> </el-aside>
<el-main> <el-main>
<!-- Preparation Page -->
<div v-if="activeMenu === 'preparation'"> <div v-if="activeMenu === 'preparation'">
<h2>准备工作</h2> <h2>准备工作</h2>
@@ -270,9 +427,58 @@ watch(currentDir, (newPath) => {
</div> </div>
<!-- Review Page -->
<div v-else-if="activeMenu === 'review'"> <div v-else-if="activeMenu === 'review'">
<h2>审核</h2> <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>
<div v-else-if="activeMenu === 'check'"> <div v-else-if="activeMenu === 'check'">
@@ -309,7 +515,85 @@ body {
border-top: 1px solid var(--el-border-color); border-top: 1px solid var(--el-border-color);
} }
.box-card { .box-card {
max-width: 800px; /* max-width: 800px; */
margin-bottom: 20px; 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>