Feat: 增加项目和 Prompt 偏好设置

This commit is contained in:
奇趣保罗 2025-11-04 22:22:33 +08:00
parent 61dee0b127
commit 85d197f149
6 changed files with 260 additions and 57 deletions

View File

@ -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)) {

View File

@ -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);

View File

@ -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);

View File

@ -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));
} }

View File

@ -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;

View File

@ -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>
); );
} }