diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f812437c..3741a386 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -335,6 +335,11 @@ Text release is paced, not animated: the frame buffer commits at most once per a - **States:** default, hover, active, focus, disabled, loading, fallback notice. - **Visibility:** the initial cards, one per consultation domain, remain visible while the user types a custom question. Clicking a card starts that consultation immediately instead of filling the composer; the cards leave once the session receives its first user message. +### Personal report centre + +- A ready report keeps “查看报告” as the primary document action and may add the quieter “专业参考版(导出)” action beside it. The reference action downloads Markdown through the authenticated same-origin report route; it is absent for generating and failed records. +- Export work uses the shared inline spinner inside the initiating button, reports a short row-local error, and never changes the stored report or starts a second writing flow. + ### Product entrypoint card - **Structure:** the homepage daily-reading and birth-time cards are single native-button targets stretched across their article surface. Content remains semantic card copy. On two-column viewports a compact action label and arrow sit at the trailing edge; on a stacked homepage they sit under the supporting line, leading-aligned, so a short label like “深入看今日” does not float to the opposite corner from “开始新的生时校正”. diff --git a/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts b/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts new file mode 100644 index 00000000..b273a9b2 --- /dev/null +++ b/frontend/src/app/api/reports/[reportId]/professional-reference/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from "next/server"; + +import { + checkSameOrigin, + resolveAllowedReportOrigins, +} from "@/lib/personal-report-entitlement"; +import { createSupabasePersonalReportService } from "@/lib/personal-report-service"; +import { + ACCOUNT_BIRTH_SELECT, + globalBirthProfileFromAccountRow, +} from "@/lib/server-owned-birth-profile"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; + +type RouteContext = { params: Promise<{ reportId: string }> }; + +type ProfessionalReferenceResponse = Readonly<{ + format?: unknown; + markdown?: unknown; +}>; + +function birthPayload(row: unknown): Record | null { + const profile = globalBirthProfileFromAccountRow(row); + const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(profile.date ?? ""); + const timeMatch = /^(\d{2}):(\d{2})$/.exec(profile.time ?? ""); + if ( + !dateMatch + || !timeMatch + || profile.latitude === null + || profile.longitude === null + || profile.timezoneOffset === null + ) { + return null; + } + return { + year: Number.parseInt(dateMatch[1], 10), + month: Number.parseInt(dateMatch[2], 10), + day: Number.parseInt(dateMatch[3], 10), + hour: Number.parseInt(timeMatch[1], 10), + minute: Number.parseInt(timeMatch[2], 10), + lat: profile.latitude, + lon: profile.longitude, + tz: profile.timezoneOffset, + ayanamsa: profile.ayanamsa, + format: "markdown", + packs: ["full"], + }; +} + +function upstreamError(status: number, retryAfter: string | null) { + return NextResponse.json( + { error: status === 429 ? "专业参考版生成繁忙,请稍后重试" : "专业参考版暂时无法生成" }, + { + status, + headers: retryAfter ? { "Retry-After": retryAfter } : undefined, + }, + ); +} + +export async function POST(request: Request, context: RouteContext) { + try { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const originDecision = checkSameOrigin( + request.url, + request.headers.get("origin"), + resolveAllowedReportOrigins(process.env), + request.headers, + ); + if (!originDecision.ok) { + return NextResponse.json({ error: "跨站请求已拒绝" }, { status: 403 }); + } + + const { reportId } = await context.params; + if (!uuidPattern.test(reportId)) { + return NextResponse.json({ error: "报告不存在" }, { status: 404 }); + } + + const report = await createSupabasePersonalReportService(supabase).getOwnedById(user.id, reportId); + if (!report) { + return NextResponse.json({ error: "报告不存在" }, { status: 404 }); + } + if (report.status !== "ready") { + return NextResponse.json({ error: "报告尚未完成" }, { status: 409 }); + } + + const { data: profileRow, error: profileError } = await supabase + .from("profiles") + .select(ACCOUNT_BIRTH_SELECT) + .eq("id", user.id) + .maybeSingle(); + const payload = profileError ? null : birthPayload(profileRow); + if (!payload) { + return NextResponse.json({ error: "出生资料不完整" }, { status: 422 }); + } + + const upstream = await fetch(`${jyotishApiBase}/api/professional_report_reference`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload), + cache: "no-store", + }); + if (!upstream.ok) { + return upstreamError(upstream.status, upstream.headers.get("retry-after")); + } + + const result = await upstream.json().catch(() => null) as ProfessionalReferenceResponse | null; + if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) { + return upstreamError(502, null); + } + return NextResponse.json({ format: "markdown", markdown: result.markdown }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "数据库尚未配置" }, { status: 503 }); + } + console.error("professional_report_reference_failed", error instanceof Error ? error.name : "UnknownError"); + return NextResponse.json({ error: "专业参考版暂时无法生成" }, { status: 500 }); + } +} diff --git a/frontend/src/components/personal-report/personal-report-center.tsx b/frontend/src/components/personal-report/personal-report-center.tsx index 70107c2e..4f474880 100644 --- a/frontend/src/components/personal-report/personal-report-center.tsx +++ b/frontend/src/components/personal-report/personal-report-center.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GeneratePersonalReportButton } from "./generate-personal-report-button"; import { Button } from "@/components/ui/button"; import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll"; +import { downloadMarkdownReport } from "@/lib/consultation-report-export"; const LIST_POLL_INTERVAL_MS = 3000; @@ -85,6 +86,8 @@ function StatusIcon({ status }: { status: ReportListItem["status"] }) { export function PersonalReportCenter() { const [state, setState] = useState({ phase: "loading", reports: [] }); + const [exportingReportId, setExportingReportId] = useState(null); + const [exportError, setExportError] = useState<{ reportId: string; message: string } | null>(null); const cancelled = useRef(false); const load = useCallback(async (showLoading = false) => { @@ -136,6 +139,31 @@ export function PersonalReportCenter() { [state.reports], ); + const downloadProfessionalReference = useCallback(async (reportId: string) => { + setExportingReportId(reportId); + setExportError(null); + try { + const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, { + method: "POST", + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + const result: unknown = await response.json().catch(() => null); + const payload = result && typeof result === "object" ? result as Record : {}; + if (!response.ok || payload.format !== "markdown" || typeof payload.markdown !== "string") { + throw new Error(typeof payload.error === "string" ? payload.error : "专业参考版暂时无法生成"); + } + downloadMarkdownReport("个人专业参考版", payload.markdown); + } catch (error) { + setExportError({ + reportId, + message: error instanceof Error ? error.message : "专业参考版暂时无法生成", + }); + } finally { + setExportingReportId(null); + } + }, []); + if (state.phase === "unauthorized") { return (
@@ -201,9 +229,25 @@ export function PersonalReportCenter() { {formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"} {report.status === "ready" ? ( - +
+ + + {exportError?.reportId === report.id ? ( + + {exportError.message} + + ) : null} +
) : report.status === "generating" ? (