Feat: 新增首页和编辑器页面,支持导出导出

This commit is contained in:
2025-11-03 16:47:16 +08:00
parent d2ba984a75
commit f2acb91f95
16 changed files with 1614 additions and 166 deletions
-42
View File
@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
-35
View File
@@ -1,35 +0,0 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App
@@ -0,0 +1,92 @@
import { memo, useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { unflattenValues } from "@/lib/i18n-structure";
type Props = {
open: boolean;
onOpenChange: (next: boolean) => void;
languages: string[];
valuesByLang: Record<string, Record<string, string>>;
};
function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) {
const [selected, setSelected] = useState<string>("");
useEffect(() => {
if (open) {
setSelected((prev) => (prev && languages.includes(prev) ? prev : languages[0] ?? ""));
}
}, [open, languages]);
const jsonText = useMemo(() => {
if (!selected) return "";
const flat = valuesByLang[selected] ?? {};
const nested = unflattenValues(flat);
try {
return JSON.stringify(nested, null, 2);
} catch {
return "{}";
}
}, [selected, valuesByLang]);
function handleDownload() {
if (!selected) return;
const blob = new Blob([jsonText || "{}"], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${selected}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>导出语言 JSON</DialogTitle>
</DialogHeader>
<div className="space-y-3 min-w-0">
<div className="flex flex-wrap gap-2">
{languages.length === 0 ? (
<div className="text-sm text-muted-foreground">暂无可导出的语言</div>
) : (
languages.map((lang) => (
<button
key={lang}
type="button"
onClick={() => setSelected(lang)}
className={`px-3 h-8 rounded border text-sm ${selected === lang ? "bg-accent" : ""}`}
>
{lang}
</button>
))
)}
</div>
<div className="border rounded-md overflow-hidden">
<textarea className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre" readOnly>
{jsonText}
</textarea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
<Button onClick={handleDownload} disabled={!selected || !jsonText}>导出 JSON</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const ExportLanguageModal = memo(ExportLanguageModalImpl);
@@ -0,0 +1,143 @@
import { memo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { buildStructureFromObject, flattenValues } from "@/lib/i18n-structure";
import { upsertLanguageTranslations, upsertStructure } from "@/lib/db";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type Props = {
open: boolean;
onOpenChange: (next: boolean) => void;
projectId: string;
hasStructure: boolean;
onImported: () => void | Promise<void>;
};
function ImportLanguageModalImpl({
open,
onOpenChange,
projectId,
hasStructure,
onImported,
}: Props) {
const [lang, setLang] = useState("");
const [importing, setImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (
!fileRef.current ||
!fileRef.current.files ||
fileRef.current.files.length === 0
) {
setError("请选择 JSON 文件");
return;
}
const language = lang.trim();
if (!language) {
setError("请输入语言代号,如 en 或 zh-CN");
return;
}
setImporting(true);
setError(null);
try {
const file = fileRef.current.files[0]!;
const text = await file.text();
let json: unknown;
try {
json = JSON.parse(text);
} catch {
throw new Error("JSON 解析失败,请检查文件内容");
}
if (!hasStructure) {
const root = buildStructureFromObject(json);
await upsertStructure({ projectId, root });
}
const values = flattenValues(json);
await upsertLanguageTranslations(projectId, language, values);
setLang("");
if (fileRef.current) fileRef.current.value = "";
onOpenChange(false);
await onImported();
} catch (e) {
setError((e as Error)?.message ?? "导入失败");
} finally {
setImporting(false);
}
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!importing) {
onOpenChange(v);
if (!v) setError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{hasStructure ? "导入语言 JSON" : "导入翻译 JSON 并构建结构"}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="mt-2 space-y-3">
<div>
<label className="text-sm text-muted-foreground">
选择 JSON 文件
</label>
<Input
ref={fileRef}
type="file"
accept="application/json,.json"
className="mt-1 block w-full text-sm"
/>
</div>
<div>
<label className="text-sm text-muted-foreground">
语言代号(如 en、ja、zh-CN)
</label>
<Input
placeholder="en"
value={lang}
onChange={(e) => setLang(e.target.value)}
aria-label="语言代号"
/>
</div>
{error && (
<div className="text-sm text-red-600" role="alert">
{error}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
setError(null);
}}
disabled={importing}
>
取消
</Button>
<Button type="submit" disabled={importing}>
{importing ? "导入中..." : "确认导入"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export const ImportLanguageModal = memo(ImportLanguageModalImpl);
+141
View File
@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+20 -15
View File
@@ -2,20 +2,25 @@ import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
);
}
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
ref={ref}
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,95 @@
import { useRef, useState } from "react";
import { upsertLanguageTranslations } from "@/lib/db";
export type ValuesByLang = Record<string, Record<string, string>>;
function makeKey(path: string, lang: string) {
return `${path}||${lang}`;
}
export function useTranslationInlineEdit(opts: {
projectId?: string;
valuesByLang: ValuesByLang;
setValuesByLang: React.Dispatch<React.SetStateAction<ValuesByLang>>;
onError?: (message: string) => void;
}) {
const { projectId, valuesByLang, setValuesByLang, onError } = opts;
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState<string>("");
const [savingKey, setSavingKey] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
function startEdit(path: string, lang: string) {
const key = makeKey(path, lang);
const current = valuesByLang[lang]?.[path] ?? "";
setEditingKey(key);
setEditingValue(current);
setTimeout(() => inputRef.current?.focus(), 0);
}
function cancelEdit() {
setEditingKey(null);
setEditingValue("");
}
function isEditingCell(path: string, lang: string) {
return editingKey === makeKey(path, lang);
}
function isSavingCell(path: string, lang: string) {
return savingKey === makeKey(path, lang);
}
function getDisplayValue(path: string, lang: string) {
return valuesByLang[lang]?.[path] ?? "";
}
async function saveEdit() {
if (!projectId || !editingKey) return;
if (savingKey === editingKey) return;
const [path, lang] = editingKey.split("||");
const prev = valuesByLang[lang] ?? {};
const next = { ...prev, [path]: editingValue };
setSavingKey(editingKey);
try {
await upsertLanguageTranslations(projectId, lang, next);
setValuesByLang((old) => ({ ...old, [lang]: next }));
cancelEdit();
} catch (e) {
onError?.((e as Error)?.message ?? "保存失败");
} finally {
setSavingKey(null);
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
void saveEdit();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
}
}
return {
// state
editingKey,
editingValue,
savingKey,
inputRef,
// selectors
isEditingCell,
isSavingCell,
getDisplayValue,
// actions
startEdit,
cancelEdit,
setEditingValue,
saveEdit,
handleKeyDown,
} as const;
}
-66
View File
@@ -4,18 +4,6 @@
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
@@ -50,60 +38,6 @@
--sidebar-ring: oklch(0.704 0.04 256.788);
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
+252
View File
@@ -0,0 +1,252 @@
export type Project = {
id: string;
name: string;
createdAt: number; // ms timestamp
updatedAt: number; // ms timestamp
};
export type StructureNode = {
key: string;
type: "group" | "entry";
children?: StructureNode[];
};
export type ProjectStructure = {
projectId: string;
root: StructureNode;
};
export type ProjectLanguageTranslations = {
id: string; // `${projectId}:${language}`
projectId: string;
language: string; // e.g. en, zh-CN, ja
values: Record<string, string>; // path -> value
};
const DB_NAME = "i18n-translate-it";
const DB_VERSION = 2;
const STORE_PROJECTS = "projects";
const STORE_STRUCTURES = "structures"; // keyPath: projectId
const STORE_TRANSLATIONS = "translations"; // keyPath: id, index by projectId
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_PROJECTS)) {
db.createObjectStore(STORE_PROJECTS, { keyPath: "id" });
}
if (!db.objectStoreNames.contains(STORE_STRUCTURES)) {
db.createObjectStore(STORE_STRUCTURES, { keyPath: "projectId" });
}
if (!db.objectStoreNames.contains(STORE_TRANSLATIONS)) {
const store = db.createObjectStore(STORE_TRANSLATIONS, { keyPath: "id" });
store.createIndex("byProject", "projectId", { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () => {
reject(new Error("IndexedDB blocked: please close other tabs."));
};
});
}
function promisifyRequest<T>(request: IDBRequest<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function generateId(length = 12): string {
const alphabet =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const bytes = new Uint8Array(length);
if (globalThis.crypto && globalThis.crypto.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < length; i += 1)
bytes[i] = Math.floor(Math.random() * 256);
}
let id = "";
for (let i = 0; i < length; i += 1)
id += alphabet[bytes[i] % alphabet.length];
return id;
}
export async function listProjects(): Promise<Project[]> {
const db = await openDb();
try {
const tx = db.transaction(STORE_PROJECTS, "readonly");
const store = tx.objectStore(STORE_PROJECTS);
const getAllReq = store.getAll();
const result = await promisifyRequest<Project[]>(getAllReq);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
// sort by createdAt desc by default when listing
return (result ?? []).sort((a, b) => b.createdAt - a.createdAt);
} finally {
db.close();
}
}
export async function getProject(id: string): Promise<Project | undefined> {
const db = await openDb();
try {
const tx = db.transaction(STORE_PROJECTS, "readonly");
const store = tx.objectStore(STORE_PROJECTS);
const req = store.get(id);
const result = await promisifyRequest<Project | undefined>(
req as IDBRequest<Project | undefined>
);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return result;
} finally {
db.close();
}
}
export async function createProject(name: string): Promise<Project> {
const trimmed = name.trim();
if (!trimmed) throw new Error("项目名称不能为空");
const project: Project = {
id: generateId(12),
name: trimmed,
createdAt: Date.now(),
updatedAt: Date.now(),
};
const db = await openDb();
try {
const tx = db.transaction(STORE_PROJECTS, "readwrite");
const store = tx.objectStore(STORE_PROJECTS);
const putReq = store.add(project);
await promisifyRequest(putReq);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return project;
} finally {
db.close();
}
}
export async function deleteProject(id: string): Promise<void> {
const db = await openDb();
try {
const tx = db.transaction(STORE_PROJECTS, "readwrite");
const store = tx.objectStore(STORE_PROJECTS);
const delReq = store.delete(id);
await promisifyRequest(delReq);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}
// ----- Structures -----
export async function getStructure(projectId: string): Promise<ProjectStructure | undefined> {
const db = await openDb();
try {
const tx = db.transaction(STORE_STRUCTURES, "readonly");
const store = tx.objectStore(STORE_STRUCTURES);
const req = store.get(projectId);
const result = await promisifyRequest<ProjectStructure | undefined>(req as IDBRequest<ProjectStructure | undefined>);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return result;
} finally {
db.close();
}
}
export async function upsertStructure(structure: ProjectStructure): Promise<void> {
const db = await openDb();
try {
const tx = db.transaction(STORE_STRUCTURES, "readwrite");
const store = tx.objectStore(STORE_STRUCTURES);
const putReq = store.put(structure);
await promisifyRequest(putReq);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}
// ----- Translations -----
export async function upsertLanguageTranslations(projectId: string, language: string, values: Record<string, string>): Promise<void> {
const id = `${projectId}:${language}`;
const record: ProjectLanguageTranslations = { id, projectId, language, values };
const db = await openDb();
try {
const tx = db.transaction(STORE_TRANSLATIONS, "readwrite");
const store = tx.objectStore(STORE_TRANSLATIONS);
const putReq = store.put(record);
await promisifyRequest(putReq);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}
export async function getLanguageTranslations(projectId: string, language: string): Promise<ProjectLanguageTranslations | undefined> {
const id = `${projectId}:${language}`;
const db = await openDb();
try {
const tx = db.transaction(STORE_TRANSLATIONS, "readonly");
const store = tx.objectStore(STORE_TRANSLATIONS);
const req = store.get(id);
const result = await promisifyRequest<ProjectLanguageTranslations | undefined>(req as IDBRequest<ProjectLanguageTranslations | undefined>);
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
return result;
} finally {
db.close();
}
}
export async function listLanguages(projectId: string): Promise<string[]> {
const db = await openDb();
try {
const tx = db.transaction(STORE_TRANSLATIONS, "readonly");
const store = tx.objectStore(STORE_TRANSLATIONS);
const index = store.index("byProject");
const allReq = index.getAll(IDBKeyRange.only(projectId));
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
const langs = new Set<string>();
for (const r of records) langs.add(r.language);
return Array.from(langs);
} finally {
db.close();
}
}
+86
View File
@@ -0,0 +1,86 @@
import { type StructureNode } from "@/lib/db";
export type FlatEntry = { path: string; name: string };
export function buildStructureFromObject(obj: unknown): StructureNode {
const root: StructureNode = { key: "$root", type: "group", children: [] };
function walk(currentKey: string, value: unknown): StructureNode {
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const children: StructureNode[] = [];
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
children.push(walk(k, v));
}
return { key: currentKey, type: "group", children };
}
return { key: currentKey, type: "entry" };
}
if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
root.children!.push(walk(k, v));
}
} else {
root.children!.push({ key: "value", type: "entry" });
}
return root;
}
export function flattenEntries(root: StructureNode): FlatEntry[] {
const out: FlatEntry[] = [];
function dfs(node: StructureNode, parentPath: string[]) {
if (node.type === "entry") {
const pathArr = [...parentPath, node.key].filter(Boolean);
out.push({ path: pathArr.join("."), name: node.key });
return;
}
const next = node.key === "$root" ? parentPath : [...parentPath, node.key];
for (const child of node.children ?? []) dfs(child, next);
}
dfs(root, []);
return out;
}
export function flattenValues(obj: unknown): Record<string, string> {
const map: Record<string, string> = {};
function dfs(value: unknown, parentPath: string[]) {
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
dfs(v, [...parentPath, k]);
}
return;
}
const path = parentPath.join(".");
map[path] = typeof value === "string" ? value : String(value);
}
if (obj !== null && typeof obj === "object" && !Array.isArray(obj)) {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) dfs(v, [k]);
}
return map;
}
export function unflattenValues(values: Record<string, string>): Record<string, unknown> {
const root: Record<string, unknown> = {};
for (const [path, val] of Object.entries(values)) {
if (!path) continue;
const parts = path.split(".");
let cur: Record<string, unknown> = root;
for (let i = 0; i < parts.length; i += 1) {
const key = parts[i]!;
const isLeaf = i === parts.length - 1;
if (isLeaf) {
cur[key] = val;
} else {
const next = cur[key];
if (next && typeof next === "object" && !Array.isArray(next)) {
cur = next as Record<string, unknown>;
} else {
const obj: Record<string, unknown> = {};
cur[key] = obj;
cur = obj;
}
}
}
}
return root;
}
+22 -8
View File
@@ -1,10 +1,24 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import "./index.css";
import Home from "./pages/home.tsx";
import Editor from "./pages/editor.tsx";
createRoot(document.getElementById('root')!).render(
const router = createBrowserRouter([
{
path: "/",
element: <Home />,
},
{
path: "/editor/:id",
element: <Editor />,
},
]);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
<RouterProvider router={router} />,
</StrictMode>
);
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
getProject,
getStructure,
listLanguages,
type Project,
type ProjectStructure,
getLanguageTranslations,
} from "@/lib/db";
import { ArrowLeft } from "lucide-react";
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
import { flattenEntries, type FlatEntry } from "@/lib/i18n-structure";
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
export default function Editor() {
const { id: projectId } = useParams();
const [project, setProject] = useState<Project | null>(null);
const [structure, setStructure] = useState<ProjectStructure | null>(null);
const [languages, setLanguages] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [pageError, setPageError] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
async function refresh() {
if (!projectId) return;
setLoading(true);
setPageError(null);
try {
const p = await getProject(projectId);
if (!p) throw new Error("项目不存在");
setProject(p);
const s = await getStructure(projectId);
if (s) setStructure(s);
const langs = await listLanguages(projectId);
setLanguages(langs);
} catch (e) {
setPageError((e as Error)?.message ?? "加载失败");
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
const entries: FlatEntry[] = useMemo(() => {
if (!structure) return [];
return flattenEntries(structure.root);
}, [structure]);
const [valuesByLang, setValuesByLang] = useState<Record<string, Record<string, string>>>({});
const inline = useTranslationInlineEdit({
projectId,
valuesByLang,
setValuesByLang,
onError: (m) => setPageError(m),
});
useEffect(() => {
if (!projectId || languages.length === 0) return;
let cancelled = false;
(async () => {
const all: Record<string, Record<string, string>> = {};
for (const lang of languages) {
const rec = await getLanguageTranslations(projectId, lang);
all[lang] = rec?.values ?? {};
}
if (!cancelled) setValuesByLang(all);
})();
return () => {
cancelled = true;
};
}, [projectId, languages]);
return (
<div className="h-screen flex flex-col">
<header className="h-14 border-b px-2 md:px-4">
<div className="grid grid-cols-3 h-full items-center">
<div className="flex items-center gap-2">
<Button asChild size="icon" variant="ghost" aria-label="返回">
<Link to="/">
<ArrowLeft className="size-5" />
</Link>
</Button>
</div>
<div className="text-center font-medium truncate">
{project?.name ?? "编辑器"}
</div>
<div className="flex items-center justify-end gap-2">
{!structure ? (
<Button onClick={() => { setImportOpen(true); }}>构建翻译结构</Button>
) : (
<Button variant="outline" onClick={() => { setImportOpen(true); }}>导入语言 JSON</Button>
)}
{languages.length > 0 && (
<Button variant="outline" onClick={() => setExportOpen(true)}>导出 JSON</Button>
)}
</div>
</div>
</header>
<main className="flex-1 overflow-auto p-4 md:p-6">
{pageError && (
<div className="mb-4 text-sm text-red-600" role="alert">{pageError}</div>
)}
{loading ? (
<div className="text-sm text-muted-foreground">加载中...</div>
) : !structure ? (
<div className="rounded-md border border-dashed p-6">
<div className="text-sm text-muted-foreground">
该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
</div>
<div className="mt-3">
<Button onClick={() => { setImportOpen(true); }}>构建翻译结构</Button>
</div>
</div>
) : (
<div>
<div className="overflow-auto border rounded-md">
<table className="min-w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 w-[320px]">翻译条目名称</th>
{languages.map((lang) => (
<th key={lang} className="text-left px-3 py-2 whitespace-nowrap">{lang}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.path} className="border-t">
<td className="px-3 py-2 font-mono whitespace-nowrap">{entry.path}</td>
{languages.map((lang) => {
const isEditing = inline.isEditingCell(entry.path, lang);
const isSaving = inline.isSavingCell(entry.path, lang);
const displayValue = inline.getDisplayValue(entry.path, lang);
return (
<td
key={`${entry.path}:${lang}`}
className="px-3 py-2 text-foreground/90 align-top"
>
{isEditing ? (
<Input
ref={inline.inputRef}
value={inline.editingValue}
onChange={(e) => inline.setEditingValue(e.target.value)}
onBlur={inline.saveEdit}
onKeyDown={inline.handleKeyDown}
disabled={isSaving}
className="h-8"
/>
) : (
<button
type="button"
className="w-full text-left min-h-8 leading-8 px-2 rounded hover:bg-accent"
onClick={() => inline.startEdit(entry.path, lang)}
title="点击编辑"
>
{displayValue || <span className="text-muted-foreground">点击编辑</span>}
</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</main>
<ImportLanguageModal
open={importOpen}
onOpenChange={setImportOpen}
projectId={projectId ?? ""}
hasStructure={!!structure}
onImported={refresh}
/>
<ExportLanguageModal
open={exportOpen}
onOpenChange={setExportOpen}
languages={languages}
valuesByLang={valuesByLang}
/>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { createProject, deleteProject, listProjects, type Project } from "@/lib/db";
function formatTime(ts: number) {
try {
return new Date(ts).toLocaleString();
} catch {
return String(ts);
}
}
function App() {
const [projectName, setProjectName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [projects, setProjects] = useState<Project[]>([]);
const [error, setError] = useState<string | null>(null);
async function refresh() {
setLoading(true);
try {
const data = await listProjects();
setProjects(data);
} catch (e) {
setError((e as Error)?.message ?? "加载失败");
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
}, []);
const canSubmit = useMemo(() => projectName.trim().length > 0 && !submitting, [projectName, submitting]);
async function onCreate(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
await createProject(projectName.trim());
setProjectName("");
await refresh();
} catch (e) {
setError((e as Error)?.message ?? "创建失败");
} finally {
setSubmitting(false);
}
}
async function onDelete(id: string) {
// 简单确认,避免误删
if (!confirm("确认删除该项目?此操作不可恢复")) return;
try {
await deleteProject(id);
await refresh();
} catch (e) {
setError((e as Error)?.message ?? "删除失败");
}
}
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<h1 className="text-2xl font-semibold">翻译项目</h1>
<form onSubmit={onCreate} className="mt-6 flex items-center gap-3">
<Input
placeholder="输入项目名称"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
aria-label="项目名称"
/>
<Button type="submit" disabled={!canSubmit}>
{submitting ? "创建中..." : "新建项目"}
</Button>
</form>
{error && (
<div className="mt-4 text-sm text-red-600" role="alert">
{error}
</div>
)}
<div className="mt-8">
<h2 className="mb-3 text-lg font-medium">项目列表</h2>
{loading ? (
<div className="text-sm text-muted-foreground">加载中...</div>
) : projects.length === 0 ? (
<div className="text-sm text-muted-foreground">暂无项目,创建一个开始吧。</div>
) : (
<ul className="space-y-3">
{projects.map((p) => (
<li
key={p.id}
className="flex items-center justify-between rounded-md border px-4 py-3"
>
<div className="min-w-0">
<div className="truncate font-medium">{p.name}</div>
<div className="mt-1 text-xs text-muted-foreground truncate">
ID: {p.id} · 创建时间: {formatTime(p.createdAt)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button asChild size="sm" variant="outline">
<Link to={`/editor/${p.id}`}>打开</Link>
</Button>
<Button size="sm" variant="destructive" onClick={() => onDelete(p.id)}>
删除
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default App;