Feat: 增加项目和 Prompt 偏好设置
This commit is contained in:
parent
61dee0b127
commit
85d197f149
|
|
@ -15,10 +15,11 @@ type Props = {
|
||||||
onOpenChange: (next: boolean) => void;
|
onOpenChange: (next: boolean) => void;
|
||||||
languages: string[];
|
languages: string[];
|
||||||
path: string;
|
path: string;
|
||||||
|
prompt: string | undefined;
|
||||||
onConfirm: (result: Record<string, string>) => Promise<void> | void;
|
onConfirm: (result: Record<string, string>) => Promise<void> | void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AiTranslateModalImpl({ open, onOpenChange, languages, path, onConfirm }: Props) {
|
function AiTranslateModalImpl({ open, onOpenChange, languages, path, prompt, onConfirm }: Props) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -37,7 +38,7 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, onConfirm }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await requestTranslations({ text: payload, languages });
|
const result = await requestTranslations({ text: payload, languages, prompt });
|
||||||
// 二次校验 keys 完整性
|
// 二次校验 keys 完整性
|
||||||
for (const l of languages) {
|
for (const l of languages) {
|
||||||
if (!Object.prototype.hasOwnProperty.call(result, l)) {
|
if (!Object.prototype.hasOwnProperty.call(result, l)) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { memo, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import type { Project } from "@/lib/db";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
project: Project | null;
|
||||||
|
onSave: (update: { name: string; preferences: { aiPrompt?: string } }) => Promise<void> | void;
|
||||||
|
onDelete: () => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ProjectSettingsModalImpl({ open, onOpenChange, project, onSave, onDelete }: Props) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [aiPrompt, setAiPrompt] = useState<string>("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(project?.name ?? "");
|
||||||
|
setAiPrompt(project?.preferences?.aiPrompt ?? "");
|
||||||
|
setSaving(false);
|
||||||
|
setErr(null);
|
||||||
|
}
|
||||||
|
}, [open, project]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return setErr("项目名称不能为空");
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
name: trimmed,
|
||||||
|
preferences: { aiPrompt },
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm("确认删除整个项目?该操作不可恢复!")) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onDelete();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? "删除失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!saving) onOpenChange(v); }}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>项目设置</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-muted-foreground">项目名称</label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="输入项目名称" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-muted-foreground">翻译偏好设置(AI Prompt)</label>
|
||||||
|
<textarea
|
||||||
|
className="mt-1 w-full min-h-32 rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none font-mono"
|
||||||
|
placeholder="请输入提供给 AI 的提示词(Prompt),描述风格、用词偏好、命名规则等..."
|
||||||
|
value={aiPrompt}
|
||||||
|
onChange={(e) => setAiPrompt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{err && <div className="text-sm text-red-600">{err}</div>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button className="mr-auto" variant="destructive" onClick={handleDelete} disabled={saving}>删除项目</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>取消</Button>
|
||||||
|
<Button type="submit" disabled={saving}>保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectSettingsModal = memo(ProjectSettingsModalImpl);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4,6 +4,7 @@ export type AiTranslateParams = {
|
||||||
text: string;
|
text: string;
|
||||||
languages: string[];
|
languages: string[];
|
||||||
model?: string;
|
model?: string;
|
||||||
|
prompt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getClient() {
|
function getClient() {
|
||||||
|
|
@ -20,6 +21,7 @@ export async function requestTranslations({
|
||||||
text,
|
text,
|
||||||
languages,
|
languages,
|
||||||
model,
|
model,
|
||||||
|
prompt,
|
||||||
}: AiTranslateParams): Promise<Record<string, string>> {
|
}: AiTranslateParams): Promise<Record<string, string>> {
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
const mdl =
|
const mdl =
|
||||||
|
|
@ -32,6 +34,8 @@ export async function requestTranslations({
|
||||||
const instruction = [
|
const instruction = [
|
||||||
"你是一个专业的翻译助手。",
|
"你是一个专业的翻译助手。",
|
||||||
`请将用户输入的文本翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`,
|
`请将用户输入的文本翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`,
|
||||||
|
"翻译偏好:",
|
||||||
|
prompt,
|
||||||
"严格要求:",
|
"严格要求:",
|
||||||
"- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。",
|
"- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。",
|
||||||
"- JSON 的键必须严格等于目标语言代码,值为对应翻译的纯文本字符串。",
|
"- JSON 的键必须严格等于目标语言代码,值为对应翻译的纯文本字符串。",
|
||||||
|
|
@ -39,11 +43,11 @@ export async function requestTranslations({
|
||||||
'示例:{"en":"Cat","zh-Hans":"猫"}',
|
'示例:{"en":"Cat","zh-Hans":"猫"}',
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
const prompt = [instruction, "\n用户文本:\n" + text].join("\n\n");
|
const promptResult = [instruction, "\n用户文本:\n" + text].join("\n\n");
|
||||||
|
|
||||||
const response = await client.responses.create({
|
const response = await client.responses.create({
|
||||||
model: mdl,
|
model: mdl,
|
||||||
input: prompt,
|
input: promptResult,
|
||||||
response_format: { type: "json_object" },
|
response_format: { type: "json_object" },
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ export type Project = {
|
||||||
name: string;
|
name: string;
|
||||||
createdAt: number; // ms timestamp
|
createdAt: number; // ms timestamp
|
||||||
updatedAt: number; // ms timestamp
|
updatedAt: number; // ms timestamp
|
||||||
|
preferences?: {
|
||||||
|
aiPrompt?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StructureNode = {
|
export type StructureNode = {
|
||||||
|
|
@ -143,23 +146,6 @@ export async function createProject(name: string): Promise<Project> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 -----
|
// ----- Structures -----
|
||||||
export async function getStructure(projectId: string): Promise<ProjectStructure | undefined> {
|
export async function getStructure(projectId: string): Promise<ProjectStructure | undefined> {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
|
|
@ -251,6 +237,72 @@ export async function listLanguages(projectId: string): Promise<string[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProject(update: Partial<Project> & { id: string }): Promise<Project> {
|
||||||
|
const db = await openDb();
|
||||||
|
try {
|
||||||
|
// read existing
|
||||||
|
const readTx = db.transaction(STORE_PROJECTS, "readonly");
|
||||||
|
const readStore = readTx.objectStore(STORE_PROJECTS);
|
||||||
|
const getReq = readStore.get(update.id);
|
||||||
|
const existing = await promisifyRequest<Project | undefined>(getReq as IDBRequest<Project | undefined>);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
readTx.oncomplete = () => resolve();
|
||||||
|
readTx.onerror = () => reject(readTx.error);
|
||||||
|
readTx.onabort = () => reject(readTx.error);
|
||||||
|
});
|
||||||
|
if (!existing) throw new Error("项目不存在");
|
||||||
|
|
||||||
|
const merged: Project = {
|
||||||
|
...existing,
|
||||||
|
...update,
|
||||||
|
preferences: { ...(existing.preferences ?? {}), ...(update.preferences ?? {}) },
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeTx = db.transaction(STORE_PROJECTS, "readwrite");
|
||||||
|
const writeStore = writeTx.objectStore(STORE_PROJECTS);
|
||||||
|
await promisifyRequest(writeStore.put(merged));
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
writeTx.oncomplete = () => resolve();
|
||||||
|
writeTx.onerror = () => reject(writeTx.error);
|
||||||
|
writeTx.onabort = () => reject(writeTx.error);
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProjectDeep(projectId: string): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
try {
|
||||||
|
const tx = db.transaction([STORE_PROJECTS, STORE_STRUCTURES, STORE_TRANSLATIONS], "readwrite");
|
||||||
|
const projectsStore = tx.objectStore(STORE_PROJECTS);
|
||||||
|
const structuresStore = tx.objectStore(STORE_STRUCTURES);
|
||||||
|
const translationsStore = tx.objectStore(STORE_TRANSLATIONS);
|
||||||
|
|
||||||
|
// delete project
|
||||||
|
await promisifyRequest(projectsStore.delete(projectId));
|
||||||
|
// delete structure
|
||||||
|
await promisifyRequest(structuresStore.delete(projectId));
|
||||||
|
// delete translations for project
|
||||||
|
const index = translationsStore.index("byProject");
|
||||||
|
const getAllReq = index.getAll(IDBKeyRange.only(projectId));
|
||||||
|
const records = await promisifyRequest<ProjectLanguageTranslations[]>(getAllReq as IDBRequest<ProjectLanguageTranslations[]>);
|
||||||
|
for (const rec of records) {
|
||||||
|
await promisifyRequest(translationsStore.delete(rec.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getAllLanguageTranslations(projectId: string): Promise<ProjectLanguageTranslations[]> {
|
export async function getAllLanguageTranslations(projectId: string): Promise<ProjectLanguageTranslations[]> {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
try {
|
try {
|
||||||
|
|
@ -280,7 +332,8 @@ export async function deleteEntryFromAllLanguages(projectId: string, path: strin
|
||||||
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
|
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
|
||||||
for (const rec of records) {
|
for (const rec of records) {
|
||||||
if (rec.values && Object.prototype.hasOwnProperty.call(rec.values, path)) {
|
if (rec.values && Object.prototype.hasOwnProperty.call(rec.values, path)) {
|
||||||
const { [path]: _, ...rest } = rec.values;
|
const rest = { ...rec.values } as Record<string, string>;
|
||||||
|
delete rest[path];
|
||||||
rec.values = rest;
|
rec.values = rest;
|
||||||
await promisifyRequest(store.put(rec));
|
await promisifyRequest(store.put(rec));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams, useNavigate } from "react-router";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
upsertStructure,
|
upsertStructure,
|
||||||
deleteEntryFromAllLanguages,
|
deleteEntryFromAllLanguages,
|
||||||
renameEntryInAllLanguages,
|
renameEntryInAllLanguages,
|
||||||
|
updateProject,
|
||||||
|
deleteProjectDeep,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react";
|
import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react";
|
||||||
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
||||||
|
|
@ -21,6 +23,7 @@ import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath,
|
||||||
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
||||||
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
|
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
|
||||||
import { EntryNameModal } from "@/components/biz/entry-name-modal";
|
import { EntryNameModal } from "@/components/biz/entry-name-modal";
|
||||||
|
import { ProjectSettingsModal } from "@/components/biz/project-settings-modal";
|
||||||
import { AiTranslateModal } from "@/components/biz/ai-translate-modal";
|
import { AiTranslateModal } from "@/components/biz/ai-translate-modal";
|
||||||
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
|
import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,6 +38,7 @@ import { toast } from "sonner";
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const { id: projectId } = useParams();
|
const { id: projectId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [structure, setStructure] = useState<ProjectStructure | null>(null);
|
const [structure, setStructure] = useState<ProjectStructure | null>(null);
|
||||||
const [languages, setLanguages] = useState<string[]>([]);
|
const [languages, setLanguages] = useState<string[]>([]);
|
||||||
|
|
@ -50,6 +54,7 @@ export default function Editor() {
|
||||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||||
const [fullMatch, setFullMatch] = useState(false);
|
const [fullMatch, setFullMatch] = useState(false);
|
||||||
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
|
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
const { copy } = useClipboard();
|
const { copy } = useClipboard();
|
||||||
|
|
||||||
|
|
@ -250,7 +255,7 @@ export default function Editor() {
|
||||||
</td>
|
</td>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang]);
|
}, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang, copy]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId || languages.length === 0) return;
|
if (!projectId || languages.length === 0) return;
|
||||||
|
|
@ -291,6 +296,9 @@ export default function Editor() {
|
||||||
{languages.length > 0 && (
|
{languages.length > 0 && (
|
||||||
<Button variant="outline" onClick={() => setExportOpen(true)}>导出 JSON</Button>
|
<Button variant="outline" onClick={() => setExportOpen(true)}>导出 JSON</Button>
|
||||||
)}
|
)}
|
||||||
|
{project && (
|
||||||
|
<Button variant="outline" onClick={() => setSettingsOpen(true)}>项目设置</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -367,11 +375,28 @@ export default function Editor() {
|
||||||
orderedPaths={entries.map((e) => e.path)}
|
orderedPaths={entries.map((e) => e.path)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProjectSettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
project={project}
|
||||||
|
onSave={async (update) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
const next = await updateProject({ id: projectId, name: update.name, preferences: update.preferences });
|
||||||
|
setProject(next);
|
||||||
|
}}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
await deleteProjectDeep(projectId);
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<AiTranslateModal
|
<AiTranslateModal
|
||||||
open={!!aiModal?.open}
|
open={!!aiModal?.open}
|
||||||
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||||
languages={languages}
|
languages={languages}
|
||||||
path={aiModal?.path ?? ""}
|
path={aiModal?.path ?? ""}
|
||||||
|
prompt={project?.preferences?.aiPrompt}
|
||||||
onConfirm={async (translations) => {
|
onConfirm={async (translations) => {
|
||||||
if (!projectId || !aiModal) return;
|
if (!projectId || !aiModal) return;
|
||||||
const targetPath = aiModal.path;
|
const targetPath = aiModal.path;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { createProject, deleteProject, listProjects, type Project } from "@/lib/db";
|
import { createProject, deleteProjectDeep, listProjects, type Project, updateProject } from "@/lib/db";
|
||||||
|
import { ProjectSettingsModal } from "@/components/biz/project-settings-modal";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
function formatTime(ts: number) {
|
function formatTime(ts: number) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -18,6 +20,9 @@ function App() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [currentProject, setCurrentProject] = useState<Project | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -53,20 +58,9 @@ function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete(id: string) {
|
|
||||||
// 简单确认,避免误删
|
|
||||||
if (!confirm("确认删除该项目?此操作不可恢复")) return;
|
|
||||||
try {
|
|
||||||
await deleteProject(id);
|
|
||||||
await refresh();
|
|
||||||
} catch (e) {
|
|
||||||
setError((e as Error)?.message ?? "删除失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||||
<h1 className="text-2xl font-semibold">翻译项目</h1>
|
<h1 className="text-2xl font-semibold">翻译它!</h1>
|
||||||
|
|
||||||
<form onSubmit={onCreate} className="mt-6 flex items-center gap-3">
|
<form onSubmit={onCreate} className="mt-6 flex items-center gap-3">
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -93,31 +87,62 @@ function App() {
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground">暂无项目,创建一个开始吧。</div>
|
<div className="text-sm text-muted-foreground">暂无项目,创建一个开始吧。</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
<li
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="flex items-center justify-between rounded-md border px-4 py-3"
|
className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative"
|
||||||
|
onClick={() => navigate(`/editor/${p.id}`)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<button
|
||||||
<div className="truncate font-medium">{p.name}</div>
|
type="button"
|
||||||
<div className="mt-1 text-xs text-muted-foreground truncate">
|
aria-label="项目设置"
|
||||||
ID: {p.id} · 创建时间: {formatTime(p.createdAt)}
|
className="absolute top-2 right-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCurrentProject(p);
|
||||||
|
setSettingsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</button>
|
||||||
|
<div className="truncate font-medium pr-8">{p.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">创建时间: {formatTime(p.createdAt)}</div>
|
||||||
|
{p.preferences?.aiPrompt ? (
|
||||||
|
<div className="mt-3 text-xs line-clamp-3 text-muted-foreground">
|
||||||
|
偏好:{p.preferences.aiPrompt}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
<ProjectSettingsModal
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
project={currentProject}
|
||||||
|
onSave={async (update) => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
const next = await updateProject({ id: currentProject.id, name: update.name, preferences: update.preferences });
|
||||||
|
setProjects((arr) => arr.map((it) => (it.id === next.id ? next : it)));
|
||||||
|
setCurrentProject(next);
|
||||||
|
}}
|
||||||
|
onDelete={async () => {
|
||||||
|
if (!currentProject) return;
|
||||||
|
await deleteProjectDeep(currentProject.id);
|
||||||
|
setSettingsOpen(false);
|
||||||
|
setCurrentProject(null);
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue