// assistant-artifact.jsx — safe mobile artifact renderer const ARTIFACT_COLORS = typeof TABI_COLORS !== 'undefined' ? TABI_COLORS : { ink: '#18140F', ink2: '#574B40', ink3: '#8D8071', bg: '#F7F0E5', bg2: '#EEE2D3', card: '#FFFAF2', line: 'rgba(24,20,15,0.10)', line2: 'rgba(24,20,15,0.16)', red: '#B94A35', redDeep: '#8F3728', green: '#55705A', blue: '#365A77', warn: '#BE8A35', gold: '#D8B66B', shadowSoft: '0 8px 22px rgba(55,42,31,0.08)', }; const ARTIFACT_SERIF = typeof tabiSerif !== 'undefined' ? tabiSerif : `"Noto Serif JP","Shippori Mincho","Hiragino Mincho ProN",serif`; function AssistantArtifactScreen({ go, artifactId }) { const [artifact, setArtifact] = React.useState(null); const [error, setError] = React.useState(''); const [loading, setLoading] = React.useState(true); React.useEffect(() => { if (!artifactId) { setError('找不到報告 ID'); setLoading(false); return; } setLoading(true); fetch(`/api/assistant/artifacts/${artifactId}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }) .then(r => r.ok ? r.json() : Promise.reject(new Error('讀取報告失敗'))) .then(data => { setArtifact(data); setError(''); }) .catch(e => setError(e.message || '讀取報告失敗')) .finally(() => setLoading(false)); }, [artifactId]); const content = artifact?.content || {}; const money = (amount, currency = content.metrics?.currency) => { if (currency === 'JPY') return `¥${Number(amount || 0).toLocaleString()}`; if (currency === 'TWD') return `NT$${Number(amount || 0).toLocaleString()}`; return `${currency || ''} ${Number(amount || 0).toLocaleString()}`.trim(); }; const moneyList = (rows, fallbackAmount, fallbackCurrency) => { if (Array.isArray(rows) && rows.length) return rows.map(row => money(row.amount, row.currency)).join(' / '); return money(fallbackAmount, fallbackCurrency); }; const rangeText = { all: '整趟旅程', today: '今日', yesterday: '昨日', last_7_days: '最近 7 天', }[content.date_range] || '自訂範圍'; const PageHeader = () => (
AI ARTIFACT
{content.title || artifact?.title || '旅程支出報告'}
); const MetricGrid = () => { const metrics = content.metrics || {}; const items = [ ['總支出', moneyList(metrics.total_by_currency, metrics.total, metrics.currency)], ['我的合計', moneyList(metrics.my_by_currency, metrics.my_total, metrics.currency)], ['個人', moneyList(metrics.personal_by_currency, metrics.personal_total, metrics.currency)], ['共同', moneyList(metrics.shared_by_currency, metrics.shared_total, metrics.currency)], ]; return (
{items.map(([label, value]) => (
{label}
14 ? 16 : 20, lineHeight: 1.25, color: ARTIFACT_COLORS.ink, fontWeight: 950 }}>{value}
))}
); }; const BarSection = ({ title, data }) => { const rows = (data || []).filter(d => Number(d.amount || 0) > 0).slice(0, 7); const max = Math.max(1, ...rows.map(d => d.amount || 0)); return (
{rows.length === 0 ? : rows.map(row => (
{row.label || row.name} {money(row.amount, row.currency)}
))}
); }; const DonutSection = () => { const data = content.series?.visibility || []; const usable = data.filter(row => row.currency === (content.metrics?.currency || row.currency)); const total = usable.reduce((sum, row) => sum + Number(row.amount || 0), 0); const personal = usable.find(row => row.name === '個人')?.amount || 0; const pct = total ? Math.round((personal / total) * 100) : 0; return (
{pct}%
{data.map((row, idx) => (
{row.label || row.name} {money(row.amount, row.currency)}
))}
); }; const LineSection = () => { const data = content.series?.daily || []; const W = 320, H = 130, pad = 16; const max = Math.max(1, ...data.map(d => d.amount || 0)); const points = data.map((d, i) => { const x = data.length <= 1 ? W / 2 : pad + i * ((W - pad * 2) / (data.length - 1)); const y = H - pad - ((d.amount || 0) / max) * (H - pad * 2); return [x, y]; }); const path = points.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); return (
{data.length === 0 ? : data.length === 1 ? (
單日資料
{data[0].date}
{money(data[0].amount, data[0].currency)}
) : ( <> {points.map((p, i) => )}
{data[0]?.date || ''} {data[data.length - 1]?.date || ''}
)}
); }; const ExpenseTable = () => { const rows = content.rows || []; return (
{rows.length === 0 ? : rows.map(row => (
{row.title}
{row.date} · {row.category} · {row.visibility === 'shared' ? '共同' : '個人'}
{money(row.amount, row.currency)}
))}
); }; const Insight = () => (
AI INSIGHT
{content.insight || '目前資料還不多,等支出增加後我可以幫你看趨勢、比例和異常項目。'}
); const renderSection = (type) => { if (typeof type === 'object' && type) { if (type.type === 'table') { return (
{(type.rows || []).length === 0 ? : (type.rows || []).map((row, idx) => (
{row.label}
{row.value}
))}
); } if (type.type === 'insight') { return (
AI INSIGHT
{type.text}
); } return null; } if (type === 'metric_grid') return ; if (type === 'daily_line') return ; if (type === 'category_bar') return ; if (type === 'member_bar') return ; if (type === 'visibility_donut') return ; if (type === 'expense_table') return ; if (type === 'insight') return ; return null; }; return (
{loading &&
讀取報告中...
} {error &&
{error}
} {!loading && !error && (
TRIP EXPENSE REPORT

{content.title || artifact?.title}

{content.trip?.name || '旅程'} · {rangeText} · {content.metrics?.expense_count || 0} 筆
{(content.sections || ['metric_grid']).map(renderSection)}
)}
); } const sectionStyle = { borderRadius: 18, padding: 14, background: ARTIFACT_COLORS.card, border: `1px solid ${ARTIFACT_COLORS.line}`, boxShadow: '0 10px 24px rgba(55,42,31,0.06)', }; function SectionTitle({ title }) { return
{title}
; } function EmptyLine() { return
這個範圍目前沒有可顯示的資料。
; }