feat: 로봇 부품 카드에 참고링크(구매처/대체품/업그레이드/레포 등) 추가
각 부품 카드에 라벨+URL 쌍을 여러 개 붙일 수 있는 "정보" 섹션 추가 — 파는 곳, 대체품, 업그레이드 옵션, 관련 GitHub 레포 등 무엇이든 자유롭게 기록. URL을 입력하면 옆에 ↗ 열기 버튼이 자동으로 뜬다(타이핑 중 포커스가 끊기지 않게 그 버튼만 DOM에서 직접 갱신, 전체 리렌더 안 함). - robot-storage.ts: RobotPart에 links: RobotPartLink[] 필드 추가(기존 저장된 데이터는 링크 없이 시작, 별도 마이그레이션 불필요) - robot-project.ts: add_part 기본값에 links:[] 추가, get 응답에 부품별 링크 목록도 같이 표시 - robot-app.html: 카드 펼침 영역에 라벨/URL 입력 행 + 추가/삭제 버튼 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,12 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface RobotPartLink {
|
||||
id: string;
|
||||
label: string; // 예: "쿠팡", "대체품", "업그레이드", "GitHub 레포"
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RobotPart {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -14,6 +20,7 @@ export interface RobotPart {
|
||||
unitPrice: number;
|
||||
status: '검토중' | '주문완료' | '보유';
|
||||
memo: string;
|
||||
links: RobotPartLink[];
|
||||
}
|
||||
|
||||
export interface RobotTask {
|
||||
@@ -43,19 +50,19 @@ export function robotProjectPath(workspaceRoot: string): string {
|
||||
export function defaultRobotSeed(): RobotProjectData {
|
||||
return {
|
||||
parts: [
|
||||
{ id: 'p1', name: '라즈베리파이5 8GB(중고)', qty: 1, unitPrice: 200000, status: '주문완료', memo: '' },
|
||||
{ id: 'p2', name: 'RPLidar A1(중고)', qty: 1, unitPrice: 60000, status: '주문완료', memo: '' },
|
||||
{ id: 'p3', name: '메카넘휠 4WD 이중데크 섀시 키트', qty: 1, unitPrice: 44000, status: '검토중', memo: 'TT모터4+메카넘휠4+섀시2단' },
|
||||
{ id: 'p4', name: '4채널 모터드라이버(L298N 등)', qty: 1, unitPrice: 10000, status: '검토중', memo: '' },
|
||||
{ id: 'p5', name: 'MPU-6050 자이로/가속도', qty: 1, unitPrice: 2500, status: '검토중', memo: '' },
|
||||
{ id: 'p6', name: '18650 셀 6개(3S2P)', qty: 1, unitPrice: 0, status: '보유', memo: '기존 보유분 재사용' },
|
||||
{ id: 'p7', name: '3S-40A BMS', qty: 1, unitPrice: 2310, status: '검토중', memo: '' },
|
||||
{ id: 'p8', name: '18650 홀더(6구)', qty: 1, unitPrice: 4000, status: '검토중', memo: '' },
|
||||
{ id: 'p9', name: '와이어링/커넥터', qty: 1, unitPrice: 5000, status: '검토중', memo: '' },
|
||||
{ id: 'p10', name: '나사/스탠드오프', qty: 1, unitPrice: 10000, status: '검토중', memo: '' },
|
||||
{ id: 'p11', name: '재활용 NEMA17 스테퍼×5+리드스크류+브라켓', qty: 1, unitPrice: 0, status: '보유', memo: '폐기된 Anet A8 Plus에서 회수, 팔 텔레스코픽 관절용' },
|
||||
{ id: 'p12', name: 'OAK-D-Lite 스테레오 카메라', qty: 1, unitPrice: 175000, status: '검토중', memo: '2단계(팔 추가) 때 그립 타겟팅용' },
|
||||
{ id: 'p13', name: '로봇팔 키트(SO-101 계열, 방식 미정)', qty: 1, unitPrice: 200000, status: '검토중', memo: '옵션 A/B/E 중 미결정 — project_robot_dog.md 참고' },
|
||||
{ id: 'p1', name: '라즈베리파이5 8GB(중고)', qty: 1, unitPrice: 200000, status: '주문완료', memo: '', links: [] },
|
||||
{ id: 'p2', name: 'RPLidar A1(중고)', qty: 1, unitPrice: 60000, status: '주문완료', memo: '', links: [] },
|
||||
{ id: 'p3', name: '메카넘휠 4WD 이중데크 섀시 키트', qty: 1, unitPrice: 44000, status: '검토중', memo: 'TT모터4+메카넘휠4+섀시2단', links: [] },
|
||||
{ id: 'p4', name: '4채널 모터드라이버(L298N 등)', qty: 1, unitPrice: 10000, status: '검토중', memo: '', links: [] },
|
||||
{ id: 'p5', name: 'MPU-6050 자이로/가속도', qty: 1, unitPrice: 2500, status: '검토중', memo: '', links: [] },
|
||||
{ id: 'p6', name: '18650 셀 6개(3S2P)', qty: 1, unitPrice: 0, status: '보유', memo: '기존 보유분 재사용', links: [] },
|
||||
{ id: 'p7', name: '3S-40A BMS', qty: 1, unitPrice: 2310, status: '검토중', memo: '', links: [] },
|
||||
{ id: 'p8', name: '18650 홀더(6구)', qty: 1, unitPrice: 4000, status: '검토중', memo: '', links: [] },
|
||||
{ id: 'p9', name: '와이어링/커넥터', qty: 1, unitPrice: 5000, status: '검토중', memo: '', links: [] },
|
||||
{ id: 'p10', name: '나사/스탠드오프', qty: 1, unitPrice: 10000, status: '검토중', memo: '', links: [] },
|
||||
{ id: 'p11', name: '재활용 NEMA17 스테퍼×5+리드스크류+브라켓', qty: 1, unitPrice: 0, status: '보유', memo: '폐기된 Anet A8 Plus에서 회수, 팔 텔레스코픽 관절용', links: [] },
|
||||
{ id: 'p12', name: 'OAK-D-Lite 스테레오 카메라', qty: 1, unitPrice: 175000, status: '검토중', memo: '2단계(팔 추가) 때 그립 타겟팅용', links: [] },
|
||||
{ id: 'p13', name: '로봇팔 키트(SO-101 계열, 방식 미정)', qty: 1, unitPrice: 200000, status: '검토중', memo: '옵션 A/B/E 중 미결정 — project_robot_dog.md 참고', links: [] },
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
|
||||
@@ -29,6 +29,9 @@ function formatProject(data: RobotProjectData): string {
|
||||
lines.push('## 부품');
|
||||
for (const p of data.parts) {
|
||||
lines.push(`- [${p.status}] ${p.name} × ${p.qty} = ${fmtWon(p.qty * p.unitPrice)}${p.memo ? ` (${p.memo})` : ''}`);
|
||||
for (const link of p.links || []) {
|
||||
lines.push(` - ${link.label}: ${link.url}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 작업');
|
||||
@@ -122,6 +125,7 @@ export const robotProjectTool = {
|
||||
unitPrice: Number.isFinite(Number(args?.unit_price)) ? Math.max(0, Number(args.unit_price)) : 0,
|
||||
status,
|
||||
memo: String(args?.memo || ''),
|
||||
links: [],
|
||||
};
|
||||
data.parts.push(part);
|
||||
saveRobotProject(workspaceRoot, data);
|
||||
|
||||
@@ -99,6 +99,16 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
.rb-part-field.qty{width:64px;}
|
||||
.rb-part-field.price{width:110px;}
|
||||
.rb-part-field.status{width:100px;}
|
||||
.rb-part-links{margin-top:10px;}
|
||||
.rb-part-links label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;}
|
||||
.rb-link-row{display:flex;gap:6px;margin-top:6px;align-items:center;}
|
||||
.rb-link-row input[type=text]{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
|
||||
.rb-link-row input:focus{border-color:#f59e0b;}
|
||||
.rb-link-row input.rb-link-label{flex:0 0 110px;}
|
||||
.rb-link-open{background:none;border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--muted);text-decoration:none;flex-shrink:0;}
|
||||
.rb-link-open:hover{border-color:#f59e0b;color:#f59e0b;}
|
||||
.rb-link-add{margin-top:6px;background:none;border:1px dashed var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
|
||||
.rb-link-add:hover{border-color:#f59e0b;color:#f59e0b;}
|
||||
.rb-part-actions{display:flex;justify-content:flex-end;margin-top:8px;}
|
||||
.rb-del-btn{background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:11px;color:var(--muted);padding:4px 10px;}
|
||||
.rb-del-btn:hover{color:#ef4444;border-color:#ef4444;}
|
||||
@@ -452,6 +462,18 @@ function renderParts(){
|
||||
<div class="rb-part-row">
|
||||
<div class="rb-part-field grow"><label>메모</label><input type="text" value="${escAttr(p.memo)}" oninput="updatePart('${p.id}','memo',this.value)"></div>
|
||||
</div>
|
||||
<div class="rb-part-links">
|
||||
<label>정보(구매처/대체품/업그레이드/레포 등)</label>
|
||||
${(p.links||[]).map(link=>`
|
||||
<div class="rb-link-row" data-link-id="${escAttr(link.id)}">
|
||||
<input type="text" class="rb-link-label" placeholder="라벨(예: 쿠팡)" value="${escAttr(link.label)}" oninput="updateLink('${p.id}','${link.id}','label',this.value)">
|
||||
<input type="text" placeholder="URL" value="${escAttr(link.url)}" oninput="updateLink('${p.id}','${link.id}','url',this.value)">
|
||||
${link.url?`<a class="rb-link-open" href="${escAttr(link.url)}" target="_blank" rel="noopener">↗</a>`:''}
|
||||
<button class="rb-del-btn" onclick="deleteLink('${p.id}','${link.id}')">✕</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
<button class="rb-link-add" onclick="addLink('${p.id}')">+ 링크 추가</button>
|
||||
</div>
|
||||
<div class="rb-part-actions"><button class="rb-del-btn" onclick="deletePart('${p.id}')">✕ 삭제</button></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,7 +501,7 @@ function updatePart(id,field,value){
|
||||
|
||||
function addPart(){
|
||||
const id=genId('p');
|
||||
parts.push({id,name:'',qty:1,unitPrice:0,status:'검토중',memo:''});
|
||||
parts.push({id,name:'',qty:1,unitPrice:0,status:'검토중',memo:'',links:[]});
|
||||
expandedParts.add(id);
|
||||
renderParts();
|
||||
scheduleSave();
|
||||
@@ -493,6 +515,50 @@ function deletePart(id){
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function addLink(partId){
|
||||
const p=parts.find(x=>x.id===partId);
|
||||
if(!p)return;
|
||||
if(!p.links)p.links=[];
|
||||
p.links.push({id:genId('lk'),label:'',url:''});
|
||||
renderParts();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function updateLink(partId,linkId,field,value){
|
||||
const p=parts.find(x=>x.id===partId);
|
||||
if(!p||!p.links)return;
|
||||
const link=p.links.find(x=>x.id===linkId);
|
||||
if(!link)return;
|
||||
link[field]=value;
|
||||
// url이 생기거나 없어지면 ↗ 열기 버튼만 DOM에서 직접 갱신 — 전체를 다시 그리면
|
||||
// input이 리렌더되면서 타이핑 중 커서/포커스가 끊긴다.
|
||||
if(field==='url'){
|
||||
const row=document.querySelector(`.rb-part-card[data-id="${partId}"] .rb-link-row[data-link-id="${linkId}"]`);
|
||||
if(row){
|
||||
let a=row.querySelector('.rb-link-open');
|
||||
if(value){
|
||||
if(!a){
|
||||
a=document.createElement('a');
|
||||
a.className='rb-link-open'; a.target='_blank'; a.rel='noopener'; a.textContent='↗';
|
||||
row.querySelector('.rb-del-btn').before(a);
|
||||
}
|
||||
a.href=value;
|
||||
}else if(a){
|
||||
a.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function deleteLink(partId,linkId){
|
||||
const p=parts.find(x=>x.id===partId);
|
||||
if(!p||!p.links)return;
|
||||
p.links=p.links.filter(x=>x.id!==linkId);
|
||||
renderParts();
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function updateBudget(){
|
||||
const sum=status=>parts.filter(p=>p.status===status).reduce((s,p)=>s+p.qty*p.unitPrice,0);
|
||||
const review=sum('검토중'),ordered=sum('주문완료'),owned=sum('보유');
|
||||
|
||||
Reference in New Issue
Block a user