feat: Add tags support to videos and implement tag management
This commit is contained in:
@@ -505,6 +505,7 @@ export const updateVideoDetails = (req: Request, res: Response): any => {
|
||||
// Filter allowed updates
|
||||
const allowedUpdates: any = {};
|
||||
if (updates.title !== undefined) allowedUpdates.title = updates.title;
|
||||
if (updates.tags !== undefined) allowedUpdates.tags = updates.tags;
|
||||
// Add other allowed fields here if needed in the future
|
||||
|
||||
if (Object.keys(allowedUpdates).length === 0) {
|
||||
|
||||
@@ -9,6 +9,6 @@ import * as schema from './schema';
|
||||
fs.ensureDirSync(DATA_DIR);
|
||||
|
||||
const dbPath = path.join(DATA_DIR, 'mytube.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
export const sqlite = new Database(dbPath);
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
@@ -24,6 +24,7 @@ export const videos = sqliteTable('videos', {
|
||||
description: text('description'),
|
||||
viewCount: integer('view_count'),
|
||||
duration: text('duration'),
|
||||
tags: text('tags'), // JSON stringified array of strings
|
||||
});
|
||||
|
||||
export const collections = sqliteTable('collections', {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UPLOADS_DIR,
|
||||
VIDEOS_DIR,
|
||||
} from "../config/paths";
|
||||
import { db } from "../db";
|
||||
import { db, sqlite } from "../db";
|
||||
import { collections, collectionVideos, downloads, settings, videos } from "../db/schema";
|
||||
|
||||
export interface Video {
|
||||
@@ -18,6 +18,7 @@ export interface Video {
|
||||
videoFilename?: string;
|
||||
thumbnailFilename?: string;
|
||||
createdAt: string;
|
||||
tags?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -74,6 +75,20 @@ export function initializeStorage(): void {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check and migrate tags column if needed
|
||||
try {
|
||||
const tableInfo = sqlite.prepare("PRAGMA table_info(videos)").all();
|
||||
const hasTags = (tableInfo as any[]).some((col: any) => col.name === 'tags');
|
||||
|
||||
if (!hasTags) {
|
||||
console.log("Migrating database: Adding tags column to videos table...");
|
||||
sqlite.prepare("ALTER TABLE videos ADD COLUMN tags TEXT").run();
|
||||
console.log("Migration successful.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking/migrating tags column:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -240,7 +255,10 @@ export function saveSettings(newSettings: Record<string, any>): void {
|
||||
export function getVideos(): Video[] {
|
||||
try {
|
||||
const allVideos = db.select().from(videos).orderBy(desc(videos.createdAt)).all();
|
||||
return allVideos as Video[];
|
||||
return allVideos.map(v => ({
|
||||
...v,
|
||||
tags: v.tags ? JSON.parse(v.tags) : [],
|
||||
})) as Video[];
|
||||
} catch (error) {
|
||||
console.error("Error getting videos:", error);
|
||||
return [];
|
||||
@@ -250,7 +268,13 @@ export function getVideos(): Video[] {
|
||||
export function getVideoById(id: string): Video | undefined {
|
||||
try {
|
||||
const video = db.select().from(videos).where(eq(videos.id, id)).get();
|
||||
return video as Video | undefined;
|
||||
if (video) {
|
||||
return {
|
||||
...video,
|
||||
tags: video.tags ? JSON.parse(video.tags) : [],
|
||||
} as Video;
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error("Error getting video by id:", error);
|
||||
return undefined;
|
||||
@@ -259,9 +283,13 @@ export function getVideoById(id: string): Video | undefined {
|
||||
|
||||
export function saveVideo(videoData: Video): Video {
|
||||
try {
|
||||
db.insert(videos).values(videoData as any).onConflictDoUpdate({
|
||||
const videoToSave = {
|
||||
...videoData,
|
||||
tags: videoData.tags ? JSON.stringify(videoData.tags) : undefined,
|
||||
};
|
||||
db.insert(videos).values(videoToSave as any).onConflictDoUpdate({
|
||||
target: videos.id,
|
||||
set: videoData,
|
||||
set: videoToSave,
|
||||
}).run();
|
||||
return videoData;
|
||||
} catch (error) {
|
||||
@@ -272,8 +300,22 @@ export function saveVideo(videoData: Video): Video {
|
||||
|
||||
export function updateVideo(id: string, updates: Partial<Video>): Video | null {
|
||||
try {
|
||||
const result = db.update(videos).set(updates as any).where(eq(videos.id, id)).returning().get();
|
||||
return (result as Video) || null;
|
||||
const updatesToSave = {
|
||||
...updates,
|
||||
tags: updates.tags ? JSON.stringify(updates.tags) : undefined,
|
||||
};
|
||||
// If tags is explicitly empty array, we might want to save it as '[]' or null.
|
||||
// JSON.stringify([]) is '[]', which is fine.
|
||||
|
||||
const result = db.update(videos).set(updatesToSave as any).where(eq(videos.id, id)).returning().get();
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
...result,
|
||||
tags: result.tags ? JSON.parse(result.tags) : [],
|
||||
} as Video;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error updating video:", error);
|
||||
return null;
|
||||
|
||||
@@ -777,7 +777,7 @@ function App() {
|
||||
type={bilibiliPartsInfo.type}
|
||||
/>
|
||||
|
||||
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column', width: '100%', px: { xs: 2, md: 4, lg: 8 } }}>
|
||||
<Box component="main" sx={{ flex: 1, display: 'flex', flexDirection: 'column', width: '100%', px: { xs: 1, md: 2, lg: 4 } }}>
|
||||
<AnimatedRoutes
|
||||
videos={videos}
|
||||
loading={loading}
|
||||
|
||||
76
frontend/src/components/TagsList.tsx
Normal file
76
frontend/src/components/TagsList.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ExpandLess, ExpandMore, LocalOffer } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Collapse,
|
||||
ListItemButton,
|
||||
Paper,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface TagsListProps {
|
||||
availableTags: string[];
|
||||
selectedTags: string[];
|
||||
onTagToggle: (tag: string) => void;
|
||||
}
|
||||
|
||||
const TagsList: React.FC<TagsListProps> = ({ availableTags, selectedTags, onTagToggle }) => {
|
||||
const { t } = useLanguage();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(true);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
// Auto-collapse on mobile by default
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
if (!availableTags || availableTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ bgcolor: 'transparent' }}>
|
||||
<ListItemButton onClick={() => setIsOpen(!isOpen)} sx={{ borderRadius: 1, mb: 1 }}>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
|
||||
{t('tags') || 'Tags'}
|
||||
</Typography>
|
||||
{isOpen ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItemButton>
|
||||
<Collapse in={isOpen} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, px: 2, pb: 2 }}>
|
||||
{availableTags.map(tag => {
|
||||
const isSelected = selectedTags.includes(tag);
|
||||
return (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
onClick={() => onTagToggle(tag)}
|
||||
color={isSelected ? "primary" : "default"}
|
||||
variant={isSelected ? "filled" : "outlined"}
|
||||
icon={isSelected ? <LocalOffer sx={{ fontSize: '1rem !important' }} /> : undefined}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
bgcolor: isSelected ? 'primary.dark' : 'action.hover'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagsList;
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
Pagination,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import AuthorsList from '../components/AuthorsList';
|
||||
import CollectionCard from '../components/CollectionCard';
|
||||
import Collections from '../components/Collections';
|
||||
import TagsList from '../components/TagsList';
|
||||
import VideoCard from '../components/VideoCard';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Collection, Video } from '../types';
|
||||
@@ -62,9 +64,27 @@ const Home: React.FC<HomeProps> = ({
|
||||
onDownload,
|
||||
onResetSearch
|
||||
}) => {
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
const [page, setPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 12;
|
||||
const { t } = useLanguage();
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
// Fetch tags
|
||||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
if (response.data.tags) {
|
||||
setAvailableTags(response.data.tags);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching tags:', error);
|
||||
}
|
||||
};
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
// Reset page when filters change (though currently no filters other than search which is separate)
|
||||
useEffect(() => {
|
||||
@@ -94,6 +114,13 @@ const Home: React.FC<HomeProps> = ({
|
||||
|
||||
// Filter videos to only show the first video from each collection
|
||||
const filteredVideos = videoArray.filter(video => {
|
||||
// Tag filtering
|
||||
if (selectedTags.length > 0) {
|
||||
const videoTags = video.tags || [];
|
||||
const hasMatchingTag = selectedTags.every(tag => videoTags.includes(tag));
|
||||
if (!hasMatchingTag) return false;
|
||||
}
|
||||
|
||||
// If the video is not in any collection, show it
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
@@ -112,6 +139,15 @@ const Home: React.FC<HomeProps> = ({
|
||||
});
|
||||
});
|
||||
|
||||
const handleTagToggle = (tag: string) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tag)
|
||||
? prev.filter(t => t !== tag)
|
||||
: [...prev, tag]
|
||||
);
|
||||
setPage(1); // Reset to first page when filter changes
|
||||
};
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(filteredVideos.length / ITEMS_PER_PAGE);
|
||||
const displayedVideos = filteredVideos.slice(
|
||||
@@ -119,7 +155,7 @@ const Home: React.FC<HomeProps> = ({
|
||||
page * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
const handlePageChange = (event: React.ChangeEvent<unknown>, value: number) => {
|
||||
const handlePageChange = (_: React.ChangeEvent<unknown>, value: number) => {
|
||||
setPage(value);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
@@ -272,10 +308,17 @@ const Home: React.FC<HomeProps> = ({
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={4}>
|
||||
{/* Sidebar container for Collections and Authors */}
|
||||
{/* Sidebar container for Collections, Authors, and Tags */}
|
||||
<Grid size={{ xs: 12, md: 3 }} sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
<Box sx={{ position: 'sticky', top: 80 }}>
|
||||
<Collections collections={collections} />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<TagsList
|
||||
availableTags={availableTags}
|
||||
selectedTags={selectedTags}
|
||||
onTagToggle={handleTagToggle}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AuthorsList videos={videoArray} />
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
@@ -40,6 +41,7 @@ interface Settings {
|
||||
defaultAutoLoop: boolean;
|
||||
maxConcurrentDownloads: number;
|
||||
language: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
@@ -49,8 +51,10 @@ const SettingsPage: React.FC = () => {
|
||||
defaultAutoPlay: false,
|
||||
defaultAutoLoop: false,
|
||||
maxConcurrentDownloads: 3,
|
||||
language: 'en'
|
||||
language: 'en',
|
||||
tags: []
|
||||
});
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
|
||||
|
||||
@@ -75,7 +79,10 @@ const SettingsPage: React.FC = () => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/settings`);
|
||||
setSettings(response.data);
|
||||
setSettings({
|
||||
...response.data,
|
||||
tags: response.data.tags || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
setMessage({ text: t('settingsFailed'), type: 'error' });
|
||||
@@ -93,6 +100,7 @@ const SettingsPage: React.FC = () => {
|
||||
delete settingsToSend.password;
|
||||
}
|
||||
|
||||
console.log('Saving settings:', settingsToSend);
|
||||
await axios.post(`${API_URL}/settings`, settingsToSend);
|
||||
setMessage({ text: t('settingsSaved'), type: 'success' });
|
||||
|
||||
@@ -113,6 +121,19 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (newTag && !settings.tags.includes(newTag)) {
|
||||
const updatedTags = [...settings.tags, newTag];
|
||||
setSettings(prev => ({ ...prev, tags: updatedTags }));
|
||||
setNewTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = (tagToDelete: string) => {
|
||||
const updatedTags = settings.tags.filter(tag => tag !== tagToDelete);
|
||||
setSettings(prev => ({ ...prev, tags: updatedTags }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
|
||||
@@ -214,6 +235,39 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Tags Management */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>{t('tagsManagement') || 'Tags Management'}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2, flexWrap: 'wrap' }}>
|
||||
{settings.tags && settings.tags.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
onDelete={() => handleDeleteTag(tag)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, maxWidth: 400 }}>
|
||||
<TextField
|
||||
label={t('newTag') || 'New Tag'}
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant="contained" onClick={handleAddTag}>
|
||||
{t('add') || 'Add'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}><Divider /></Grid>
|
||||
|
||||
{/* Download Settings */}
|
||||
<Grid size={12}>
|
||||
<Typography variant="h6" gutterBottom>{t('downloadSettings')}</Typography>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Fullscreen,
|
||||
FullscreenExit,
|
||||
Link as LinkIcon,
|
||||
LocalOffer,
|
||||
Loop,
|
||||
Pause,
|
||||
PlayArrow,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
@@ -96,6 +98,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
const [loadingComments, setLoadingComments] = useState<boolean>(false);
|
||||
const [showComments, setShowComments] = useState<boolean>(false);
|
||||
const [commentsLoaded, setCommentsLoaded] = useState<boolean>(false);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
@@ -223,6 +226,10 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
setIsLooping(true);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Fetched settings in VideoPlayer:', response.data);
|
||||
setAvailableTags(response.data.tags || []);
|
||||
console.log('Setting available tags:', response.data.tags || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
}
|
||||
@@ -423,6 +430,19 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTags = async (newTags: string[]) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
|
||||
if (response.data.success) {
|
||||
setVideo(prev => prev ? { ...prev, tags: newTags } : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating tags:', error);
|
||||
showSnackbar(t('error'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
@@ -643,6 +663,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
|
||||
{deleteError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{deleteError}
|
||||
@@ -681,6 +702,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
|
||||
|
||||
{videoCollections.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('collections')}:</Typography>
|
||||
@@ -702,6 +725,42 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tags Section */}
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LocalOffer color="action" fontSize="small" />
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={availableTags}
|
||||
value={video.tags || []}
|
||||
isOptionEqualToValue={(option, value) => option === value}
|
||||
onChange={(_, newValue) => handleUpdateTags(newValue)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
variant="standard"
|
||||
placeholder={!video.tags || video.tags.length === 0 ? (t('tags') || 'Tags') : ''}
|
||||
sx={{ minWidth: 200 }}
|
||||
InputProps={{ ...params.InputProps, disableUnderline: true }}
|
||||
/>
|
||||
)}
|
||||
renderTags={(value, getTagProps) =>
|
||||
value.map((option, index) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return (
|
||||
<Chip
|
||||
key={key}
|
||||
variant="outlined"
|
||||
label={option}
|
||||
size="small"
|
||||
{...tagProps}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Comments Section */}
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Video {
|
||||
totalParts?: number;
|
||||
seriesTitle?: string;
|
||||
rating?: number;
|
||||
tags?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ export const translations = {
|
||||
settingsFailed: "Failed to save settings",
|
||||
debugMode: "Debug Mode",
|
||||
debugModeDescription: "Show or hide console messages (requires refresh)",
|
||||
tagsManagement: "Tags Management",
|
||||
newTag: "New Tag",
|
||||
tags: "Tags",
|
||||
|
||||
// Database
|
||||
database: "Database",
|
||||
@@ -254,6 +257,9 @@ export const translations = {
|
||||
settingsFailed: "保存设置失败",
|
||||
debugMode: "调试模式",
|
||||
debugModeDescription: "显示或隐藏控制台消息(需要刷新)",
|
||||
tagsManagement: "标签管理",
|
||||
newTag: "新标签",
|
||||
tags: "标签",
|
||||
|
||||
// Database
|
||||
database: "数据库",
|
||||
|
||||
Reference in New Issue
Block a user