Feat: 增加项目和 Prompt 偏好设置
This commit is contained in:
parent
61dee0b127
commit
85d197f149
|
|
@ -15,10 +15,11 @@ type Props = {
|
|||
onOpenChange: (next: boolean) => void;
|
||||
languages: string[];
|
||||
path: string;
|
||||
prompt: string | undefined;
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -37,7 +38,7 @@ function AiTranslateModalImpl({ open, onOpenChange, languages, path, onConfirm }
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await requestTranslations({ text: payload, languages });
|
||||
const result = await requestTranslations({ text: payload, languages, prompt });
|
||||
// 二次校验 keys 完整性
|
||||
for (const l of languages) {
|
||||
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;
|
||||
languages: string[];
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
|
||||
function getClient() {
|
||||
|
|
@ -20,6 +21,7 @@ export async function requestTranslations({
|
|||
text,
|
||||
languages,
|
||||
model,
|
||||
prompt,
|
||||
}: AiTranslateParams): Promise<Record<string, string>> {
|
||||
const client = getClient();
|
||||
const mdl =
|
||||
|
|
@ -32,6 +34,8 @@ export async function requestTranslations({
|
|||
const instruction = [
|
||||
"你是一个专业的翻译助手。",
|
||||
`请将用户输入的文本翻译为这些目标语言:${targetList},注意英语的首字母务必是大写。`,
|
||||
"翻译偏好:",
|
||||
prompt,
|
||||
"严格要求:",
|
||||
"- 仅返回一个 JSON 对象,不包含任何其他内容(例如说明、代码块标记、注释)。",
|
||||
"- JSON 的键必须严格等于目标语言代码,值为对应翻译的纯文本字符串。",
|
||||
|
|
@ -39,11 +43,11 @@ export async function requestTranslations({
|
|||
'示例:{"en":"Cat","zh-Hans":"猫"}',
|
||||
].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({
|
||||
model: mdl,
|
||||
input: prompt,
|
||||
input: promptResult,
|
||||
response_format: { type: "json_object" },
|
||||
} as any);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ export type Project = {
|
|||
name: string;
|
||||
createdAt: number; // ms timestamp
|
||||
updatedAt: number; // ms timestamp
|
||||
preferences?: {
|
||||
aiPrompt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
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 -----
|
||||
export async function getStructure(projectId: string): Promise<ProjectStructure | undefined> {
|
||||
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[]> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
|
|
@ -280,7 +332,8 @@ export async function deleteEntryFromAllLanguages(projectId: string, path: strin
|
|||
const records = await promisifyRequest<ProjectLanguageTranslations[]>(allReq as IDBRequest<ProjectLanguageTranslations[]>);
|
||||
for (const rec of records) {
|
||||
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;
|
||||
await promisifyRequest(store.put(rec));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } 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 { Input } from "@/components/ui/input";
|
||||
import {
|
||||
|
|
@ -14,6 +14,8 @@ import {
|
|||
upsertStructure,
|
||||
deleteEntryFromAllLanguages,
|
||||
renameEntryInAllLanguages,
|
||||
updateProject,
|
||||
deleteProjectDeep,
|
||||
} from "@/lib/db";
|
||||
import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react";
|
||||
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 { ExportLanguageModal } from "@/components/biz/export-language-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 { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso";
|
||||
import {
|
||||
|
|
@ -35,6 +38,7 @@ import { toast } from "sonner";
|
|||
|
||||
export default function Editor() {
|
||||
const { id: projectId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [structure, setStructure] = useState<ProjectStructure | null>(null);
|
||||
const [languages, setLanguages] = useState<string[]>([]);
|
||||
|
|
@ -50,6 +54,7 @@ export default function Editor() {
|
|||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [fullMatch, setFullMatch] = useState(false);
|
||||
const [aiModal, setAiModal] = useState<{ open: boolean; path: string } | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const { copy } = useClipboard();
|
||||
|
||||
|
|
@ -250,7 +255,7 @@ export default function Editor() {
|
|||
</td>
|
||||
</>
|
||||
);
|
||||
}, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang]);
|
||||
}, [languages, colWidth, inline, projectId, structure, setStructure, setValuesByLang, copy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || languages.length === 0) return;
|
||||
|
|
@ -291,6 +296,9 @@ export default function Editor() {
|
|||
{languages.length > 0 && (
|
||||
<Button variant="outline" onClick={() => setExportOpen(true)}>导出 JSON</Button>
|
||||
)}
|
||||
{project && (
|
||||
<Button variant="outline" onClick={() => setSettingsOpen(true)}>项目设置</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -367,11 +375,28 @@ export default function Editor() {
|
|||
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
|
||||
open={!!aiModal?.open}
|
||||
onOpenChange={(v) => setAiModal((cur) => (cur ? { ...cur, open: v } : cur))}
|
||||
languages={languages}
|
||||
path={aiModal?.path ?? ""}
|
||||
prompt={project?.preferences?.aiPrompt}
|
||||
onConfirm={async (translations) => {
|
||||
if (!projectId || !aiModal) return;
|
||||
const targetPath = aiModal.path;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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) {
|
||||
try {
|
||||
|
|
@ -18,6 +20,9 @@ function App() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
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() {
|
||||
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 (
|
||||
<div className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="text-2xl font-semibold">翻译项目</h1>
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
<h1 className="text-2xl font-semibold">翻译它!</h1>
|
||||
|
||||
<form onSubmit={onCreate} className="mt-6 flex items-center gap-3">
|
||||
<Input
|
||||
|
|
@ -93,31 +87,62 @@ function App() {
|
|||
) : projects.length === 0 ? (
|
||||
<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) => (
|
||||
<li
|
||||
<div
|
||||
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">
|
||||
<div className="truncate font-medium">{p.name}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground truncate">
|
||||
ID: {p.id} · 创建时间: {formatTime(p.createdAt)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="项目设置"
|
||||
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>
|
||||
) : null}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue