review page, first
This commit is contained in:
@@ -3,6 +3,9 @@ use fs_extra::dir::CopyOptions;
|
||||
use tauri::Manager;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
|
||||
// Define the configuration structure
|
||||
#[derive(Serialize, Deserialize, Default, Debug)]
|
||||
@@ -11,6 +14,10 @@ pub struct AppConfig {
|
||||
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/
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
@@ -74,13 +81,178 @@ fn load_config(app_handle: tauri::AppHandle) -> Result<AppConfig, String> {
|
||||
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(¤t_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)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::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!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "review-videos",
|
||||
"width": 1050,
|
||||
"width": 1200,
|
||||
"height": 840
|
||||
}
|
||||
],
|
||||
|
||||
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