// 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 (
{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 ? (
{money(data[0].amount, data[0].currency)}
) : (
<>
{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) => (
))}
);
}
if (type.type === 'insight') {
return (
);
}
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 這個範圍目前沒有可顯示的資料。
;
}