Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions e2e/language.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test'

test('drawing UI switches between English and simplified Chinese', async ({ page }) => {
await page.goto('/app')
const toggle = page.getByTestId('language-toggle')
await expect(toggle).toBeVisible()
if (await toggle.textContent() === '中') {
await toggle.click()
}
await expect(toggle).toHaveText('EN')
await expect(page.getByRole('button', { name: '保存', exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: '属性', exact: true })).toBeVisible()
await toggle.click()
await expect(toggle).toHaveText('中')
await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeVisible()
})
13 changes: 13 additions & 0 deletions e2e/toolbar-layout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { test, expect } from '@playwright/test'

test('zoom control keeps fixed button widths on a narrow toolbar', async ({ page }) => {
await page.setViewportSize({ width: 520, height: 720 })
await page.goto('/app')
const zoom = page.locator('.tb-zoom')
const fit = page.getByTestId('tb-fit')
const box = await zoom.boundingBox()
const fitBox = await fit.boundingBox()
expect(box?.width ?? 0).toBeGreaterThanOrEqual(132)
expect(fitBox?.width ?? 0).toBeGreaterThanOrEqual(50)
await expect(fit).toBeVisible()
})
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import SheetTabs from './panels/SheetTabs'
import StatusBar from './panels/StatusBar'
import QuickLineEditor from './panels/QuickLineEditor'
import WelcomeOverlay from './panels/WelcomeOverlay'

import { useT } from './i18n'
function readPref(key: string, fallback: boolean): boolean {
try {
const v = localStorage.getItem(key)
Expand All @@ -34,19 +34,20 @@ function writePref(key: string, value: boolean): void {
* Workspace switching, the command palette and the update toast belong to the
* shell (EditorRoot) so they survive moving between workspaces. */
export default function App() {
const t = useT()
const [showPalette, setShowPalette] = useState(() => readPref('pid.ui.palette', true))
const [showProps, setShowProps] = useState(() => readPref('pid.ui.props', true))
const togglePalette = (v: boolean) => { setShowPalette(v); writePref('pid.ui.palette', v) }
const toggleProps = (v: boolean) => { setShowProps(v); writePref('pid.ui.props', v) }

return (
<div className={`app${showPalette ? '' : ' no-palette'}${showProps ? '' : ' no-props'}`}>
<div className={`${showPalette ? 'app' : 'app no-palette'}${showProps ? '' : ' no-props'}`}>
<Toolbar />
{showPalette ? (
<Palette onCollapse={() => togglePalette(false)} />
) : (
<button className="panel-strip strip-left" title="Show symbol palette" onClick={() => togglePalette(true)}>
Symbols ▸
{t('Symbols')}
</button>
)}
<div className="center">
Expand All @@ -58,7 +59,7 @@ export default function App() {
<PropertyPanel onCollapse={() => toggleProps(false)} />
) : (
<button className="panel-strip strip-right" title="Show properties" onClick={() => toggleProps(true)}>
◂ Properties
{t('Properties')}
</button>
)}
<StatusBar />
Expand Down
8 changes: 5 additions & 3 deletions src/WorkspaceRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEffect } from 'react'
import { WORKSPACES, navigateWorkspace, type Workspace } from './routes'
import { useStore } from './store/store'
import { qaFor } from './validate/engine'
import { useT } from './i18n'

interface Entry {
icon: string
Expand All @@ -26,6 +27,7 @@ const ENTRIES: Record<Workspace, Entry> = {
}

export default function WorkspaceRail({ active }: { active: Workspace }) {
const t = useT()
// Only actionable counts earn a badge. Warnings and observations would cry
// wolf on a drawing that is merely unfinished, so the badge counts CRITICALS
// alone — the things that would stop the drawing being issued.
Expand All @@ -44,7 +46,7 @@ export default function WorkspaceRail({ active }: { active: Workspace }) {
}, [])

return (
<nav className="rail" aria-label="Workspaces">
<nav className="rail" aria-label={t('Workspaces')}>
{WORKSPACES.map((w, i) => {
const e = ENTRIES[w]
const on = w === active
Expand All @@ -56,11 +58,11 @@ export default function WorkspaceRail({ active }: { active: Workspace }) {
className={`rail-btn${on ? ' on' : ''}`}
data-testid={`rail-${w}`}
aria-current={on ? 'page' : undefined}
title={`${e.label} — ${e.hint} (Ctrl+${i + 1})`}
title={`${t(e.label)} — ${t(e.hint)} (Ctrl+${i + 1})`}
onClick={() => navigateWorkspace(w)}
>
<span className="rail-icon" aria-hidden="true">{e.icon}</span>
<span className="rail-label">{e.label}</span>
<span className="rail-label">{t(e.label)}</span>
{badge !== null && (
<span className="rail-badge" aria-label={`${badge} findings`}>
{badge > 99 ? '99+' : badge}
Expand Down
60 changes: 60 additions & 0 deletions src/ai/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { ProjectDoc } from '../model/types'
import type { AiReply } from './types'

export interface AiSettings { baseUrl: string; apiKey: string; model: string }
export interface AiMessage { role: 'user' | 'assistant'; content: string }
const KEY = 'pid.ai.settings'

export function readAiSettings(): AiSettings {
try {
const saved = JSON.parse(localStorage.getItem(KEY) ?? '') as Partial<AiSettings>
const baseUrl = saved.baseUrl === 'https://api.openai.com/v1' && !saved.apiKey ? '' : (saved.baseUrl ?? '')
const model = saved.model === 'gpt-4o-mini' && !saved.apiKey ? '' : (saved.model ?? '')
return { baseUrl, apiKey: saved.apiKey ?? '', model }
} catch {
return { baseUrl: '', apiKey: '', model: '' }
}
}

export function saveAiSettings(settings: AiSettings): void {
try { localStorage.setItem(KEY, JSON.stringify(settings)) } catch { /* private mode */ }
}

function endpoint(baseUrl: string): string {
return `${baseUrl.trim().replace(/\/+$/, '')}/chat/completions`
}

export async function askAi(settings: AiSettings, doc: ProjectDoc, prompt: string, history: AiMessage[] = []): Promise<AiReply> {
if (!settings.baseUrl.trim()) throw new Error('请先填写 Base URL')
if (!settings.apiKey.trim()) throw new Error('请先填写 API Key')
if (!settings.model.trim()) throw new Error('请先填写模型名称')
const context = JSON.stringify(doc)
const system = `你是 P&ID 工艺流程图助手。你可以分析用户提供的图纸 JSON,并提出或执行受控修改。只输出 JSON,不要 Markdown,格式为 {"answer":"中文回答","operations":[]}。operations 只能使用 setNodeTag、setNodeLabel、setNodePosition、setNodeRotation、setNodeConfig、setEdgeArrow、setEdgeClass、setPendingEndpointTag、addNode、addEdge。所有已有对象 ID 必须来自图纸,新增设备必须使用已有 symbolId;新增设备如需被新增管线引用,请给 addNode 一个唯一 clientId,并在 addEdge 中使用该 clientId。不要臆造工艺事实;把假设和现场确认项写进 answer。当前图纸 JSON:${context}`
const messages = [
{ role: 'system' as const, content: system },
...history.slice(-12),
{ role: 'user' as const, content: prompt },
]
const response = await fetch(endpoint(settings.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${settings.apiKey.trim()}` },
body: JSON.stringify({ model: settings.model.trim(), temperature: 0.2, messages }),
})
if (!response.ok) throw new Error(`模型请求失败 (${response.status})`)
const body = await response.json() as { choices?: { message?: { content?: string } }[] }
const content = body.choices?.[0]?.message?.content?.trim()
if (!content) throw new Error('模型没有返回内容')
const json = content.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
const parsed = JSON.parse(json) as AiReply
if (!parsed || typeof parsed.answer !== 'string' || !Array.isArray(parsed.operations ?? [])) throw new Error('模型返回格式无效')
return parsed
}

export async function testAiConnection(settings: AiSettings): Promise<void> {
if (!settings.baseUrl.trim()) throw new Error('请先填写 Base URL')
if (!settings.apiKey.trim()) throw new Error('请先填写 API Key')
const response = await fetch(`${settings.baseUrl.trim().replace(/\/+$/, '')}/models`, {
headers: { Authorization: `Bearer ${settings.apiKey.trim()}` },
})
if (!response.ok) throw new Error(`连接失败 (${response.status})`)
}
18 changes: 18 additions & 0 deletions src/ai/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { LineClass, NodeKind, Tag } from '../model/types'

export type AiOperation =
| { type: 'setNodeTag'; nodeId: string; tag: Tag | null }
| { type: 'setNodeLabel'; nodeId: string; label: string }
| { type: 'setNodePosition'; nodeId: string; x: number; y: number }
| { type: 'setNodeRotation'; nodeId: string; rotation: 0 | 90 | 180 | 270 }
| { type: 'setNodeConfig'; nodeId: string; config: Record<string, string> }
| { type: 'setEdgeArrow'; edgeId: string; arrow: 'none' | 'flow' }
| { type: 'setEdgeClass'; edgeId: string; lineClass: LineClass }
| { type: 'setPendingEndpointTag'; edgeId: string; end: 'source' | 'target'; tag: string | null }
| { type: 'addNode'; clientId?: string; symbolId: string; kind: NodeKind; x: number; y: number; rotation?: 0 | 90 | 180 | 270; tag?: Tag; label?: string }
| { type: 'addEdge'; lineClass: LineClass; source: { nodeId: string; portId: string } | { x: number; y: number; pendingTag?: string }; target: { nodeId: string; portId: string } | { x: number; y: number; pendingTag?: string }; arrow?: 'none' | 'flow' }

export interface AiReply {
answer: string
operations?: AiOperation[]
}
33 changes: 30 additions & 3 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -158,25 +158,29 @@ button { font: inherit; }
.toolbar select { padding-right: 4px; }
.toolbar button:hover, .toolbar select:hover { background: var(--c-accent-soft); border-color: #bcc6cd; }
.toolbar button:active { background: #dfeaec; }
.toolbar button.tb-active { background: var(--c-accent-soft); border-color: var(--c-accent); color: var(--c-accent); }
.toolbar .tb-lang { min-width: 34px; padding: 3px 7px; font-weight: 700; color: var(--c-accent); }
.toolbar button:focus-visible, .toolbar select:focus-visible,
.palette-entry:focus-visible, .sheet-tab:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 1px; }
/* icon-only buttons stay square so the row reads as a grid, not a ransom note */
.toolbar button.tb-icon { width: 26px; padding: 0; font-size: 13px; }

/* zoom: one segmented control instead of three loose buttons */
.tb-zoom { display: inline-flex; align-items: center; }
.tb-zoom { display: inline-flex; align-items: center; flex: 0 0 auto; min-width: 132px; overflow: visible; }
.tb-zoom button { border-radius: 0; margin-left: -1px; }
.tb-zoom button:first-child { border-radius: var(--r) 0 0 var(--r); margin-left: 0; }
.tb-zoom button:last-child { border-radius: 0 var(--r) var(--r) 0; }
.tb-zoom button:not(.tb-zoom-pct) { width: 26px; padding: 0; font-size: 14px; }
.tb-zoom-pct { min-width: 50px; font-variant-numeric: tabular-nums; color: var(--c-ink-2); }
.tb-zoom button:not(.tb-zoom-pct) { width: 26px; min-width: 26px; padding: 0; font-size: 14px; flex: 0 0 auto; }
.tb-zoom button:last-child { min-width: 50px; padding-left: 8px; padding-right: 8px; }
.tb-zoom-pct { width: 56px; min-width: 56px; font-variant-numeric: tabular-nums; color: var(--c-ink-2); flex: 0 0 auto; }
.app-name { margin-right: 4px; }
.doc-name { color: #666; font-size: 12px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tb-sep { width: 1px; height: 18px; background: var(--c-line); margin: 0 4px; flex: none; }
.tb-grow { flex: 1; }
.tb-line { display: flex; align-items: center; gap: 5px; font-size: 12px; color: #555; }
.tb-line select { font: inherit; padding: 3px 4px; border: 1px solid #ccc; border-radius: 4px; max-width: 150px; }


.sheet-tabs { display: flex; align-items: flex-end; gap: 2px; padding: 4px 8px 0; background: var(--c-chrome-2); border-top: 1px solid var(--c-line); overflow-x: auto; flex: none; }
.sheet-tab { display: flex; align-items: center; gap: 5px; height: 25px; padding: 0 10px; border: 1px solid var(--c-line); border-bottom: none; border-radius: var(--r) var(--r) 0 0; background: #dfe2e7; color: var(--c-ink-2); font-size: 12px; cursor: pointer; white-space: nowrap; }
.sheet-tab:hover { background: #e9ebef; }
Expand All @@ -196,6 +200,29 @@ button { font: inherit; }
.search-box li.active { background: #eef2f7; }
.search-sheet { color: #888; font-size: 12px; }
.search-empty { padding: 10px 14px; color: #888; font-size: 13px; border-top: 1px solid #e5e5ea; }
.ai-overlay { position: fixed; inset: 0; z-index: 50; display: flex; justify-content: flex-end; background: rgba(20,20,30,.28); }
.ai-panel { width: min(520px, 100vw); height: 100%; overflow-y: auto; padding: 16px; background: #fff; box-shadow: -8px 0 30px rgba(0,0,0,.2); display: flex; flex-direction: column; gap: 10px; }
.ai-panel > header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e2e2e8; padding-bottom: 10px; }
.ai-settings { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.ai-settings label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #555; }
.ai-settings label:first-child { grid-column: 1 / -1; }
.ai-settings input, .ai-prompt { font: inherit; border: 1px solid #ccc; border-radius: 4px; padding: 6px 8px; }
.ai-connection { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #53616c; }
.ai-connection span { color: var(--c-accent); }
.ai-context { padding: 7px 8px; background: #f1f5f7; border-left: 3px solid var(--c-accent); color: #53616c; font-size: 12px; }
.ai-history { display: flex; flex-direction: column; gap: 7px; max-height: 230px; overflow-y: auto; }
.ai-message { display: flex; gap: 8px; padding: 7px 8px; border-radius: 4px; font-size: 12px; line-height: 1.5; }
.ai-message.user { background: #edf5f6; }
.ai-message.assistant { background: #f7f7f8; }
.ai-message b { flex: 0 0 22px; color: var(--c-accent); }
.ai-ops { padding: 8px; border: 1px solid #d9e4e7; background: #f4fafb; color: #3d5660; font-size: 12px; line-height: 1.6; }
.ai-ops div { padding-left: 8px; }
.ai-prompt { min-height: 110px; resize: vertical; }
.ai-actions { display: flex; gap: 8px; justify-content: flex-end; }
.ai-error { color: #c53030; background: #fff5f5; border: 1px solid #fed7d7; padding: 7px 8px; font-size: 12px; }
.ai-answer { white-space: pre-wrap; background: #f8fafb; border: 1px solid #e2e8f0; border-radius: 4px; padding: 10px; min-height: 100px; }
.ai-answer pre { white-space: pre-wrap; font: inherit; line-height: 1.55; }
.ai-panel small { color: #7b8790; line-height: 1.45; }
.align-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; margin-bottom: 10px; }
.align-grid button { padding: 4px 2px; font-size: 11px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
.align-grid button:hover { background: #eef2f7; }
Expand Down
24 changes: 13 additions & 11 deletions src/auth/AuthForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'
import { register, resetPassword, signIn, signInWithGoogle } from './authStore'
import { authErrorMessage } from './errors'
import './auth.css'
import { useT } from '../i18n'

type Mode = 'signin' | 'register'

Expand All @@ -22,6 +23,7 @@ export default function AuthForm({ onDone, initialMode = 'signin' }: {
onDone(): void
initialMode?: Mode
}) {
const t = useT()
const [mode, setMode] = useState<Mode>(initialMode)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
Expand Down Expand Up @@ -100,14 +102,14 @@ export default function AuthForm({ onDone, initialMode = 'signin' }: {
aria-selected={!signup} aria-controls="auth-form" data-testid="auth-tab-signin"
onClick={() => switchTo('signin')}
>
Sign in
{t('Sign in')}
</button>
<button
type="button" role="tab" className="auth-tab" id="auth-tab-register"
aria-selected={signup} aria-controls="auth-form" data-testid="auth-tab-register"
onClick={() => switchTo('register')}
>
Create account
{t('Create account')}
</button>
</div>

Expand All @@ -117,17 +119,17 @@ export default function AuthForm({ onDone, initialMode = 'signin' }: {
>
{signup && (
<div className="auth-field">
<label htmlFor="auth-name">Name</label>
<label htmlFor="auth-name">{t('Name')}</label>
<input
id="auth-name" ref={nameRef} data-testid="auth-name"
type="text" autoComplete="name" placeholder="Optional"
type="text" autoComplete="name" placeholder={t('Optional')}
value={name} onChange={(e) => setName(e.target.value)}
/>
</div>
)}

<div className="auth-field">
<label htmlFor="auth-email">Email</label>
<label htmlFor="auth-email">{t('Email')}</label>
<input
id="auth-email" ref={emailRef} data-testid="auth-email"
type="email" autoComplete="email" required
Expand All @@ -136,34 +138,34 @@ export default function AuthForm({ onDone, initialMode = 'signin' }: {
</div>

<div className="auth-field">
<label htmlFor="auth-password">Password</label>
<label htmlFor="auth-password">{t('Password')}</label>
<input
id="auth-password" data-testid="auth-password"
type="password" required
autoComplete={signup ? 'new-password' : 'current-password'}
value={password} onChange={(e) => setPassword(e.target.value)}
/>
{signup && <span className="auth-hint">At least {MIN_PASSWORD} characters.</span>}
{signup && <span className="auth-hint">{t('At least 6 characters.')}</span>}
</div>

{error && <p className="auth-error" role="alert" data-testid="auth-error">{error}</p>}
{notice && <p className="auth-note" role="status" data-testid="auth-notice">{notice}</p>}

<button className="auth-submit" type="submit" disabled={pending} data-testid="auth-submit">
{pending ? 'Working…' : signup ? 'Create account' : 'Sign in'}
{pending ? t('Working…') : signup ? t('Create account') : t('Sign in')}
</button>

{!signup && (
<div className="auth-row">
<button className="auth-link" type="button" onClick={onForgot}
disabled={pending} data-testid="auth-forgot">
Forgot password?
{t('Forgot password?')}
</button>
</div>
)}
</form>

<div className="auth-or"><span>or</span></div>
<div className="auth-or"><span>{t('or')}</span></div>

<button className="auth-google" type="button" onClick={onGoogle}
disabled={pending} data-testid="auth-google">
Expand All @@ -173,7 +175,7 @@ export default function AuthForm({ onDone, initialMode = 'signin' }: {
<path fill="#fbbc05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.95H.96a9 9 0 0 0 0 8.1l3.01-2.33Z" />
<path fill="#ea4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.59C13.46.89 11.43 0 9 0A9 9 0 0 0 .96 4.95l3.01 2.33C4.68 5.16 6.66 3.58 9 3.58Z" />
</svg>
Continue with Google
{t('Continue with Google')}
</button>

<p className="auth-fine">
Expand Down
Loading