Merge origin/main into agent-guided birth time rectification
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdminEmail } from "@/lib/supabase/admin";
|
||||
import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin";
|
||||
import {
|
||||
isSupabaseConfigurationError,
|
||||
} from "@/lib/supabase/config";
|
||||
@@ -7,6 +7,35 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type ProfilePatchPayload = {
|
||||
name?: unknown;
|
||||
birth_date?: unknown;
|
||||
birth_time?: unknown;
|
||||
country_code?: unknown;
|
||||
province_code?: unknown;
|
||||
city_code?: unknown;
|
||||
district_code?: unknown;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
timezone_offset?: unknown;
|
||||
};
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
|
||||
const message = error?.message?.toLowerCase() ?? "";
|
||||
return error?.code === "PGRST204"
|
||||
|| error?.code === "42703"
|
||||
|| message.includes("schema cache")
|
||||
|| message.includes("column");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
@@ -38,3 +67,64 @@ export async function GET() {
|
||||
return NextResponse.json({ error: "账户服务暂时不可用" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => null) as ProfilePatchPayload | null;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return NextResponse.json({ error: "账户资料格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = createAdminSupabaseClient();
|
||||
const baseProfile = {
|
||||
id: user.id,
|
||||
name: nullableString(payload.name),
|
||||
birth_date: nullableString(payload.birth_date),
|
||||
birth_time: nullableString(payload.birth_time),
|
||||
country_code: nullableString(payload.country_code),
|
||||
province_code: nullableString(payload.province_code),
|
||||
city_code: nullableString(payload.city_code),
|
||||
district_code: nullableString(payload.district_code),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
const withCoordinates = {
|
||||
...baseProfile,
|
||||
latitude: nullableNumber(payload.latitude),
|
||||
longitude: nullableNumber(payload.longitude),
|
||||
timezone_offset: nullableNumber(payload.timezone_offset),
|
||||
};
|
||||
const withoutCoordinates = baseProfile;
|
||||
let { data, error } = await admin
|
||||
.from("profiles")
|
||||
.upsert(withCoordinates, { onConflict: "id" })
|
||||
.select("id")
|
||||
.single();
|
||||
if (error && isMissingProfileColumn(error)) {
|
||||
const fallback = await admin
|
||||
.from("profiles")
|
||||
.upsert(withoutCoordinates, { onConflict: "id" })
|
||||
.select("id")
|
||||
.single();
|
||||
data = fallback.data;
|
||||
error = fallback.error;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: "暂时无法保存账户资料" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: "账户服务暂时不可用" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chinaLocations } from "@/data/china-locations";
|
||||
|
||||
type Profile = {
|
||||
date?: string;
|
||||
time?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
districtCode?: string;
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
function payloadFromProfile(profile: Profile) {
|
||||
if (!profile.date || !profile.time) return null;
|
||||
const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode);
|
||||
const city = province?.cities.find((item) => item.code === profile.cityCode);
|
||||
const district = city?.districts.find((item) => item.code === profile.districtCode);
|
||||
const location = district ?? city;
|
||||
if (!location) return null;
|
||||
return {
|
||||
birth_time: `${profile.date} ${profile.time}`,
|
||||
uncertainty_minutes: 30,
|
||||
step_minutes: 2,
|
||||
lat: location.center[1],
|
||||
lon: location.center[0],
|
||||
tz: chinaLocations.country.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchQuestionnaire(payload: Record<string, unknown>) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
const response = await fetch(`${jyotishApiBase}/api/active_rectification_questions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
|
||||
return await response.json() as Record<string, unknown>;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null) as { profile?: Profile } | null;
|
||||
const payload = payloadFromProfile(body?.profile ?? {});
|
||||
if (!payload) {
|
||||
return NextResponse.json({
|
||||
status: "blocked",
|
||||
boundary: "not_auto_rectified",
|
||||
reason: "profile_incomplete",
|
||||
});
|
||||
}
|
||||
const questionnaire = await fetchQuestionnaire(payload).catch(() => null);
|
||||
const candidateScan = questionnaire?.candidate_scan && typeof questionnaire.candidate_scan === "object"
|
||||
? questionnaire.candidate_scan
|
||||
: null;
|
||||
const questions = Array.isArray(questionnaire?.questions) ? questionnaire.questions : [];
|
||||
return NextResponse.json({
|
||||
status: questionnaire ? "ok" : "blocked",
|
||||
candidate_scan: candidateScan,
|
||||
question_count: questions.length,
|
||||
boundary: "not_auto_rectified",
|
||||
source: questionnaire ? "active_rectification_questions" : "fallback_unavailable",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const { error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.delete()
|
||||
.eq("id", id)
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "other");
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "星盘删除失败") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
|
||||
type ChartProfilePayload = {
|
||||
id?: string;
|
||||
role?: "self" | "other";
|
||||
profile?: unknown;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id, role, profile, updated_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("updated_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ profiles: data ?? [] });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "星盘库暂时不可用") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const body = await request.json().catch(() => null) as ChartProfilePayload | null;
|
||||
if (!body?.profile || typeof body.profile !== "object") {
|
||||
return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 });
|
||||
}
|
||||
const role = body.role === "self" ? "self" : "other";
|
||||
const updatedAt = new Date().toISOString();
|
||||
let data;
|
||||
let error;
|
||||
if (role === "self") {
|
||||
const existing = await supabase
|
||||
.from("chart_profiles")
|
||||
.select("id")
|
||||
.eq("user_id", user.id)
|
||||
.eq("role", "self")
|
||||
.maybeSingle();
|
||||
if (existing.error) throw existing.error;
|
||||
const query = existing.data?.id
|
||||
? supabase
|
||||
.from("chart_profiles")
|
||||
.update({ profile: body.profile, updated_at: updatedAt })
|
||||
.eq("id", existing.data.id)
|
||||
.select("id, role, profile, updated_at")
|
||||
.single()
|
||||
: supabase
|
||||
.from("chart_profiles")
|
||||
.insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt })
|
||||
.select("id, role, profile, updated_at")
|
||||
.single();
|
||||
({ data, error } = await query);
|
||||
} else {
|
||||
const record = {
|
||||
...(body.id ? { id: body.id } : {}),
|
||||
user_id: user.id,
|
||||
role,
|
||||
profile: body.profile,
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
({ data, error } = await supabase
|
||||
.from("chart_profiles")
|
||||
.upsert(record, { onConflict: "id" })
|
||||
.select("id, role, profile, updated_at")
|
||||
.single());
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ profile: data });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "星盘保存失败") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chinaLocations } from "@/data/china-locations";
|
||||
|
||||
type Profile = {
|
||||
name?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
districtCode?: string;
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
const cards = [
|
||||
{ trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" },
|
||||
{ trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" },
|
||||
{ trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" },
|
||||
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
|
||||
];
|
||||
|
||||
function pickCard(profile: Profile, today: string) {
|
||||
const seed = `${today}-${profile.date ?? ""}-${profile.time ?? ""}-${profile.provinceCode ?? ""}-${profile.cityCode ?? ""}`;
|
||||
const index = Array.from(seed).reduce((sum, char) => sum + char.charCodeAt(0), 0) % cards.length;
|
||||
return cards[index];
|
||||
}
|
||||
|
||||
function profilePayload(profile: Profile, today: string) {
|
||||
if (!profile.date || !profile.time) return null;
|
||||
const [year, month, day] = profile.date.split("-").map(Number);
|
||||
const [hour, minute] = profile.time.split(":").map(Number);
|
||||
const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode);
|
||||
const city = province?.cities.find((item) => item.code === profile.cityCode);
|
||||
const district = city?.districts.find((item) => item.code === profile.districtCode);
|
||||
const location = district ?? city;
|
||||
if (!year || !month || !day || Number.isNaN(hour) || Number.isNaN(minute) || !location) return null;
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
lat: location.center[1],
|
||||
lon: location.center[0],
|
||||
tz: chinaLocations.country.timezone,
|
||||
transit_date: today,
|
||||
today,
|
||||
ayanamsa: "lahiri",
|
||||
node_mode: "mean",
|
||||
};
|
||||
}
|
||||
|
||||
function chartPoints(chart: Record<string, unknown>) {
|
||||
const modules = chart.modules && typeof chart.modules === "object" ? chart.modules as Record<string, unknown> : {};
|
||||
const chartModule = modules.chart && typeof modules.chart === "object" ? modules.chart as Record<string, unknown> : chart;
|
||||
const planets = chartModule.planets && typeof chartModule.planets === "object" ? chartModule.planets : undefined;
|
||||
const ascendant = chartModule.ascendant && typeof chartModule.ascendant === "object" ? chartModule.ascendant : undefined;
|
||||
return planets && ascendant ? { planets, ascendant } : null;
|
||||
}
|
||||
|
||||
async function fetchJson(path: string, body: Record<string, unknown>) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2500);
|
||||
try {
|
||||
const response = await fetch(`${jyotishApiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`jyotish_api_${response.status}`);
|
||||
return await response.json() as Record<string, unknown>;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function transitBackedCard(profile: Profile, today: string) {
|
||||
const payload = profilePayload(profile, today);
|
||||
if (!payload) return null;
|
||||
const chart = await fetchJson("/api/chart", payload);
|
||||
const points = chartPoints(chart);
|
||||
if (!points) return null;
|
||||
const tomorrow = new Date(`${today}T00:00:00.000Z`);
|
||||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||
const transit = await fetchJson("/api/transit", {
|
||||
natal_planets: points.planets,
|
||||
ascendant: points.ascendant,
|
||||
start: today,
|
||||
end: tomorrow.toISOString().slice(0, 10),
|
||||
planets_to_check: ["Saturn", "Jupiter", "Rahu", "Ketu"],
|
||||
});
|
||||
const summary = transit.summary && typeof transit.summary === "object" ? transit.summary as Record<string, unknown> : {};
|
||||
const total = Number(summary.total_triggers ?? 0);
|
||||
return {
|
||||
trend: total > 0 ? `今日有 ${total} 个可观察过境触发点,适合把它当作时间窗口观察。` : "今日未发现强精确过境触发,适合按本命节奏稳步推进。",
|
||||
action: "把今日计划压缩到一件主事,并记录实际发生的触发点。",
|
||||
caution: "过境触发不能单独定事件,需与 Dasha、分盘和本命承诺交叉确认。",
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json().catch(() => null) as { profile?: Profile; today?: string } | null;
|
||||
const profile = body?.profile ?? {};
|
||||
const today = body?.today || new Date().toISOString().slice(0, 10);
|
||||
const transitCard = await transitBackedCard(profile, today).catch(() => null);
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
card: transitCard ?? pickCard(profile, today),
|
||||
source: transitCard ? "jyotish_api_transit_lite" : "calculation_lite",
|
||||
claim_status: "exploratory_unvalidated",
|
||||
boundary: "not_deterministic_prediction",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
type Check = {
|
||||
status: "ok" | "degraded" | "blocked";
|
||||
message?: string;
|
||||
latencyMs?: number;
|
||||
};
|
||||
|
||||
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
function envCheck(names: string[]): Check {
|
||||
const missing = names.filter((name) => !process.env[name]);
|
||||
return missing.length
|
||||
? { status: "blocked", message: `missing:${missing.join(",")}` }
|
||||
: { status: "ok" };
|
||||
}
|
||||
|
||||
function anyEnvCheck(names: string[]): Check {
|
||||
return names.some((name) => process.env[name])
|
||||
? { status: "ok" }
|
||||
: { status: "blocked", message: `missing_one_of:${names.join("|")}` };
|
||||
}
|
||||
|
||||
async function jyotishApiCheck(): Promise<Check> {
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const response = await fetch(`${jyotishApiBase}/api/health`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
return {
|
||||
status: response.ok ? "ok" : "degraded",
|
||||
message: response.ok ? undefined : `http:${response.status}`,
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "blocked",
|
||||
message: error instanceof Error ? error.name : "jyotish_api_unavailable",
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function aggregate(checks: Record<string, Check>) {
|
||||
if (Object.values(checks).some((check) => check.status === "blocked")) return "blocked";
|
||||
if (Object.values(checks).some((check) => check.status === "degraded")) return "degraded";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const checks = {
|
||||
web: { status: "ok" } satisfies Check,
|
||||
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
|
||||
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
|
||||
modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]),
|
||||
jyotishApi: await jyotishApiCheck(),
|
||||
};
|
||||
const status = aggregate(checks);
|
||||
return NextResponse.json(
|
||||
{
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
},
|
||||
{ status: status === "ok" ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
type SynastryReportPayload = {
|
||||
id?: string;
|
||||
partnerName?: string;
|
||||
report?: unknown;
|
||||
};
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("synastry_reports")
|
||||
.select("id, partner_name, report, created_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ reports: data ?? [] });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "合盘历史暂时不可用") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => null) as SynastryReportPayload | null;
|
||||
if (!body?.report || typeof body.report !== "object" || Array.isArray(body.report)) {
|
||||
return NextResponse.json({ error: "合盘报告格式不正确" }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
|
||||
const partnerName = body.partnerName?.trim() || "对方";
|
||||
const record = {
|
||||
...(body.id ? { id: body.id } : {}),
|
||||
user_id: user.id,
|
||||
partner_name: partnerName,
|
||||
report: body.report,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("synastry_reports")
|
||||
.insert(record)
|
||||
.select("id, partner_name, report, created_at")
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return NextResponse.json({ report: data });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: errorMessage(error, "合盘历史保存失败") }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { chinaLocations } from "@/data/china-locations";
|
||||
|
||||
type Profile = {
|
||||
name?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
countryCode?: "CN";
|
||||
provinceCode?: string;
|
||||
cityCode?: string;
|
||||
districtCode?: string;
|
||||
};
|
||||
|
||||
const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
const china = chinaLocations.country;
|
||||
|
||||
function birthPayload(profile: Profile) {
|
||||
const [year, month, day] = String(profile.date || "").split("-").map(Number);
|
||||
const [hour, minute] = String(profile.time || "").split(":").map(Number);
|
||||
const province = china.provinces.find((item) => item.code === profile.provinceCode);
|
||||
const city = province?.cities.find((item) => item.code === profile.cityCode);
|
||||
const district = city?.districts.find((item) => item.code === profile.districtCode);
|
||||
const location = district ?? city;
|
||||
if (![year, month, day, hour, minute].every(Number.isFinite) || !location) {
|
||||
throw new Error("birth_profile_incomplete");
|
||||
}
|
||||
return {
|
||||
year, month, day, hour, minute,
|
||||
second: 0,
|
||||
lat: location.center[1],
|
||||
lon: location.center[0],
|
||||
tz: china.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
function moonLongitude(chart: Record<string, unknown>) {
|
||||
const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record<string, unknown> : {};
|
||||
const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record<string, unknown> : {};
|
||||
const value = moon.lon ?? moon.longitude ?? moon.degree;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) throw new Error("moon_longitude_missing");
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function moonSummary(chart: Record<string, unknown>) {
|
||||
const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record<string, unknown> : {};
|
||||
const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record<string, unknown> : {};
|
||||
return {
|
||||
sign: moon.sign,
|
||||
nakshatra: moon.nakshatra,
|
||||
pada: moon.nakshatra_pada,
|
||||
lord: moon.nakshatra_lord,
|
||||
longitude: moonLongitude(chart),
|
||||
};
|
||||
}
|
||||
|
||||
function d9Summary(varga: Record<string, unknown>) {
|
||||
const result = varga.result && typeof varga.result === "object" ? varga.result as Record<string, unknown> : {};
|
||||
const d9 = result.D9_Navamsa && typeof result.D9_Navamsa === "object" ? result.D9_Navamsa as Record<string, unknown> : {};
|
||||
const ascendant = d9.ascendant && typeof d9.ascendant === "object" ? d9.ascendant as Record<string, unknown> : {};
|
||||
const planets = d9.planets && typeof d9.planets === "object" ? d9.planets as Record<string, unknown> : {};
|
||||
return {
|
||||
ascendant,
|
||||
moon: planets.Moon,
|
||||
venus: planets.Venus,
|
||||
mars: planets.Mars,
|
||||
source: varga.source,
|
||||
};
|
||||
}
|
||||
|
||||
function planetSign(point: unknown) {
|
||||
return point && typeof point === "object" && "sign" in point
|
||||
? String((point as Record<string, unknown>).sign || "unknown")
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function relationshipReport(synastry: Record<string, unknown>, selfD9: Record<string, unknown>, partnerD9: Record<string, unknown>) {
|
||||
const total = Number(synastry.total_score ?? 0);
|
||||
const max = Number(synastry.max_score ?? 36);
|
||||
const ratio = max > 0 ? total / max : 0;
|
||||
const band = ratio >= 0.72 ? "supportive" : ratio >= 0.5 ? "mixed" : "challenging";
|
||||
const self = d9Summary(selfD9);
|
||||
const partner = d9Summary(partnerD9);
|
||||
return {
|
||||
status: "evidence_summary",
|
||||
scoreBand: band,
|
||||
headline: band === "supportive"
|
||||
? "基础匹配度偏支持,但仍需结合现实互动与长期运势。"
|
||||
: band === "mixed"
|
||||
? "基础匹配度中等,适合重点观察沟通节奏、价值观与关系承诺。"
|
||||
: "基础匹配度偏谨慎,需要先处理冲突模式与现实条件。",
|
||||
strengths: [
|
||||
`Ashtakoot ${total}/${max}`,
|
||||
`本人 D9 Moon:${planetSign(self.moon)}`,
|
||||
`对方 D9 Moon:${planetSign(partner.moon)}`,
|
||||
],
|
||||
risks: [
|
||||
"这不是完整婚恋结论;尚未纳入双方 Dasha、UL/DK 与长期时机。",
|
||||
`D9 Venus/Mars 需要继续解释:本人 ${planetSign(self.venus)}/${planetSign(self.mars)},对方 ${planetSign(partner.venus)}/${planetSign(partner.mars)}。`,
|
||||
],
|
||||
nextEvidence: ["双方 Dasha", "UL/DK", "D9 7宫/7主", "现实关系时间线"],
|
||||
};
|
||||
}
|
||||
|
||||
async function postPython(path: string, body: unknown) {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data || typeof data !== "object") {
|
||||
throw new Error(`jyotish_api_${response.status}`);
|
||||
}
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile } | null;
|
||||
if (!body?.selfProfile || !body.partnerProfile) {
|
||||
return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 });
|
||||
}
|
||||
const selfChart = await postPython("/api/chart", birthPayload(body.selfProfile));
|
||||
const partnerChart = await postPython("/api/chart", birthPayload(body.partnerProfile));
|
||||
const selfD9 = await postPython("/api/varga_full", {
|
||||
...birthPayload(body.selfProfile),
|
||||
planets: selfChart.planets,
|
||||
ascendant: selfChart.ascendant,
|
||||
divisions: ["D9"],
|
||||
});
|
||||
const partnerD9 = await postPython("/api/varga_full", {
|
||||
...birthPayload(body.partnerProfile),
|
||||
planets: partnerChart.planets,
|
||||
ascendant: partnerChart.ascendant,
|
||||
divisions: ["D9"],
|
||||
});
|
||||
const synastry = await postPython("/api/synastry", {
|
||||
male_moon: moonLongitude(selfChart),
|
||||
female_moon: moonLongitude(partnerChart),
|
||||
});
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
method: "ashtakoot_plus_moon_nakshatra_d9",
|
||||
evidenceLayers: ["ashtakoot", "moon_nakshatra", "d9_navamsa"],
|
||||
selfChart: { moon: moonSummary(selfChart), d9: d9Summary(selfD9) },
|
||||
partnerChart: { moon: moonSummary(partnerChart), d9: d9Summary(partnerD9) },
|
||||
synastry,
|
||||
relationshipReport: relationshipReport(synastry, selfD9, partnerD9),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
status: "blocked",
|
||||
error: error instanceof Error ? error.message : "synastry_unavailable",
|
||||
message: "合盘计算暂时不可用;可先保留合盘问题草稿。",
|
||||
}, { status: 503 });
|
||||
}
|
||||
}
|
||||
@@ -267,13 +267,26 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.brand-mark, .auth-brand span { width: 32px; height: 32px; border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--color-border); }
|
||||
.auth-story-brand img { width: 32px; height: 32px; border-radius: 50%; object-fit: contain; box-shadow: 0 0 0 1px var(--color-border); }
|
||||
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: var(--sidebar-primary); color: var(--sidebar-primary-foreground); cursor: pointer; font-size: var(--type-body-sm); transition: background-color 120ms ease-out, transform 120ms ease-out; margin: var(--space-4) 0 var(--space-6); border-radius: var(--radius-md); font-weight: 500; }
|
||||
.sidebar-label { display: block; padding: 0 var(--space-3) var(--space-2); color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
|
||||
.session-nav-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); padding: 0 var(--space-3) var(--space-2); }
|
||||
.sidebar-label { min-height: 44px; display: flex; align-items: center; color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
|
||||
.session-nav-toggle { min-height: 44px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; font-size: var(--type-overline); transition: background-color 120ms ease-out, color 120ms ease-out; }
|
||||
.session-list { min-height: 0; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.session-list button { position: relative; width: 100%; display: grid; gap: 2px; border: 0; background: transparent; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; min-height: 52px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border-radius: var(--radius-md); color: var(--sidebar-muted-foreground); }
|
||||
.session-list [data-active="true"] { color: var(--sidebar-accent-foreground); background: var(--sidebar-accent); }
|
||||
.session-list [data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--sidebar-ring); }
|
||||
.session-list button > span { overflow: hidden; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; }
|
||||
.session-list button small { color: inherit; line-height: 1.3; opacity: .72; font-size: 12px; }
|
||||
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-md); color: var(--sidebar-muted-foreground); }
|
||||
.session-main { position: relative; width: 100%; min-height: 52px; display: grid; gap: 2px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
|
||||
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: var(--sidebar-accent); }
|
||||
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--sidebar-ring); }
|
||||
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-body-sm); font-weight: 500; }
|
||||
.session-title > svg { width: var(--space-3); height: var(--space-3); flex: 0 0 auto; color: var(--sidebar-ring); }
|
||||
.session-main small { color: inherit; line-height: 1.3; opacity: .72; font-size: var(--type-overline); }
|
||||
.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; opacity: .64; transition: opacity 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; }
|
||||
.session-menu-trigger > svg { width: 18px; height: 18px; }
|
||||
.session-row:hover .session-menu-trigger, .session-row:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; }
|
||||
.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
|
||||
.session-actions { position: absolute; z-index: 4; top: calc(100% - var(--space-1)); right: 0; min-width: calc(var(--space-24) + var(--space-12)); display: grid; gap: var(--space-1); padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); }
|
||||
.session-actions button { width: 100%; min-height: 44px; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font-size: var(--type-body-sm); }
|
||||
.session-actions button > svg { width: 16px; height: 16px; }
|
||||
.session-actions button:hover { background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||
.session-actions .session-action-danger { color: var(--color-danger); }
|
||||
.sidebar-footer { position: relative; margin-top: var(--space-3); padding-top: 10px; border-top: 1px solid var(--sidebar-border); }
|
||||
.profile-trigger { width: 100%; display: grid; grid-template-columns: 34px minmax(0, 1fr) 18px; align-items: center; gap: 9px; padding: 5px 7px; border: 0; background: transparent; color: var(--sidebar-foreground); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 56px; border-radius: var(--radius-md); }
|
||||
.profile-initial { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 50%; font-size: 12px; text-transform: uppercase; border: 0; background: var(--color-action); color: var(--color-on-dark); font-weight: 500; }
|
||||
@@ -404,15 +417,27 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.birth-time-candidate-result, .birth-time-candidate-result > *, .birth-time-confirmation-panel > * { min-width: 0; max-width: 100%; }
|
||||
.phrase-nowrap { white-space: nowrap; }
|
||||
|
||||
.starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: 1.08fr .92fr; grid-template-rows: 1fr 1fr; gap: var(--space-3); border: 0; }
|
||||
.starter-list button { width: 100%; display: grid; align-items: center; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 92px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 0; border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.starter-list button:first-child { min-height: 196px; grid-row: 1 / span 2; align-content: end; border: 1px solid color-mix(in srgb, var(--color-action) 18%, var(--color-border)); background: var(--color-action-soft); color: var(--color-ink); }
|
||||
.starter-list { border-top: 1px solid var(--color-border); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); border: 0; }
|
||||
.starter-list button { width: 100%; display: grid; align-items: end; border-bottom: 1px solid var(--color-border); cursor: pointer; text-align: left; transition: background-color 120ms ease-out, transform 120ms ease-out; min-height: 136px; grid-template-columns: minmax(0, 1fr) 20px; gap: var(--space-3); padding: var(--space-5); border: 1px solid transparent; border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.starter-list button:first-child { border-color: color-mix(in srgb, var(--color-action) 18%, var(--color-border)); background: var(--color-action-soft); color: var(--color-ink); }
|
||||
.starter-list button:first-child .starter-content span { color: var(--color-ink); white-space: normal; }
|
||||
.starter-list button:first-child .starter-arrow { color: var(--color-action); }
|
||||
.starter-content { min-width: 0; display: grid; gap: var(--space-2); }
|
||||
.starter-content b { color: var(--color-action); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
|
||||
.starter-content span { overflow: hidden; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); line-height: 1.4; text-overflow: clip; text-wrap: pretty; white-space: normal; }
|
||||
.starter-arrow { width: 17px; height: 17px; color: var(--color-ink-secondary); }
|
||||
.product-entrypoints { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1.12fr) minmax(0, .88fr); gap: var(--space-3); }
|
||||
.product-entrypoints button { min-height: 132px; align-items: end; border: 1px solid var(--color-border); background: var(--color-canvas); }
|
||||
.product-entrypoints span { display: grid; gap: var(--space-2); }
|
||||
.product-entrypoints small { color: var(--color-ink-secondary); line-height: 1.45; font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card, .birth-rectification-card { min-height: 132px; display: grid; gap: var(--space-3); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--color-action) 16%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-canvas); }
|
||||
.daily-starlanguage-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
||||
.daily-starlanguage-heading > span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: 1.5px; }
|
||||
.daily-starlanguage-heading button { min-height: 36px; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; color: var(--color-ink); font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card dl, .birth-rectification-card dl { display: grid; gap: var(--space-2); margin: 0; }
|
||||
.daily-starlanguage-card div, .birth-rectification-card div { display: grid; gap: 4px; }
|
||||
.daily-starlanguage-card dt, .birth-rectification-card dt { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.daily-starlanguage-card dd, .birth-rectification-card dd { margin: 0; color: var(--color-ink); line-height: 1.45; }
|
||||
.starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; }
|
||||
.starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; }
|
||||
|
||||
@@ -506,6 +531,30 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.account-redeem-form { padding-top: var(--space-5); }
|
||||
.logout-copy { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-md); line-height: 1.6; text-wrap: pretty; }
|
||||
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-6); }
|
||||
.sheet-section { border-bottom: 1px solid var(--color-border); padding: var(--space-6) 0; border-color: var(--color-border); }
|
||||
.section-toggle b, .section-heading b { display: block; font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; }
|
||||
.section-toggle small, .section-heading small { display: block; margin-top: 4px; color: var(--color-ink-secondary); font-weight: 400; font-size: var(--type-caption); }
|
||||
.default-chart-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.default-chart-card span, .default-chart-card small { display: block; color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.default-chart-card strong { display: block; margin: 4px 0; color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; }
|
||||
.chart-library-panel { display: grid; gap: var(--space-5); margin-top: var(--space-5); }
|
||||
.chart-library-group { display: grid; gap: var(--space-3); }
|
||||
.chart-library-group > b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; }
|
||||
.chart-library-item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
|
||||
.chart-library-item strong, .chart-library-item small { display: block; }
|
||||
.chart-library-item strong { color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; }
|
||||
.chart-library-item small, .chart-library-item > span, .empty-library-copy { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.chart-library-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
|
||||
.chart-library-form { padding-top: var(--space-4); border-top: 1px solid var(--color-border); }
|
||||
.synastry-report-card { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-muted); }
|
||||
.synastry-report-card span, .synastry-report-card small, .synastry-report-card li { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.synastry-report-card strong, .synastry-report-card p { color: var(--color-ink); }
|
||||
.synastry-report-card strong { display: block; margin-top: 4px; font-size: var(--type-body-md); font-weight: 500; }
|
||||
.synastry-report-card p, .synastry-report-card ul { margin: 0; }
|
||||
.synastry-history-list { display: grid; gap: var(--space-2); }
|
||||
.synastry-history-list > b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; }
|
||||
.synastry-history-item { display: grid; gap: 3px; padding: var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); text-align: left; }
|
||||
.synastry-history-item small { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
|
||||
input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; }
|
||||
input:disabled, select:disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
|
||||
@@ -567,8 +616,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
|
||||
@media (min-width: 768px) and (max-width: 900px) {
|
||||
.starter-list { grid-template-columns: 1fr; grid-template-rows: auto; }
|
||||
.starter-list button, .starter-list button:first-child { min-height: 92px; grid-row: auto; padding: var(--space-4); }
|
||||
.starter-list button:first-child { min-height: 112px; }
|
||||
.starter-list button, .starter-list button:first-child, .product-entrypoints button { min-height: 112px; grid-row: auto; padding: var(--space-4); }
|
||||
.product-entrypoints { grid-template-columns: 1fr; }
|
||||
.auth-shell { grid-template-columns: 1.2fr .8fr; }
|
||||
.auth-story, .auth-panel { padding: var(--space-8); }
|
||||
.auth-story h2 { font-size: var(--type-display-sm); }
|
||||
@@ -588,8 +637,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
.welcome { padding: var(--space-6) 0 var(--space-10); }
|
||||
.welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-display-sm); }
|
||||
.starter-list { grid-template-columns: 1fr; grid-template-rows: auto; }
|
||||
.starter-list button, .starter-list button:first-child { min-height: 96px; grid-row: auto; padding: var(--space-4); }
|
||||
.starter-list button:first-child { min-height: 136px; }
|
||||
.starter-list button, .starter-list button:first-child, .product-entrypoints button { min-height: 112px; grid-row: auto; padding: var(--space-4); }
|
||||
.product-entrypoints { grid-template-columns: 1fr; }
|
||||
.message-list { width: 100%; padding: var(--space-5) var(--space-4) var(--space-12); }
|
||||
.message-content { max-width: 88%; }
|
||||
.composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); }
|
||||
|
||||
+685
-16
@@ -56,6 +56,37 @@ type Profile = BirthTimeDraft & {
|
||||
districtCode: string;
|
||||
rectificationCaseId: string;
|
||||
};
|
||||
type ChartLibraryRecord = {
|
||||
id: string;
|
||||
role: "self" | "other";
|
||||
profile: Profile;
|
||||
updatedAt: number;
|
||||
};
|
||||
type ChartLibraryApiRecord = {
|
||||
id: string;
|
||||
role: "self" | "other";
|
||||
profile: Profile;
|
||||
updated_at?: string;
|
||||
};
|
||||
type SynastryReportCard = {
|
||||
id: string;
|
||||
partnerName: string;
|
||||
score?: number;
|
||||
maxScore?: number;
|
||||
assessment?: string;
|
||||
headline?: string;
|
||||
scoreBand?: string;
|
||||
strengths?: string[];
|
||||
risks?: string[];
|
||||
nextEvidence?: string[];
|
||||
createdAt: number;
|
||||
};
|
||||
type SynastryReportApiRecord = {
|
||||
id: string;
|
||||
partner_name?: string;
|
||||
report?: SynastryReportCard;
|
||||
created_at?: string;
|
||||
};
|
||||
type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number };
|
||||
type RequestError = { sessionId: string; message: string };
|
||||
type StreamingReply = { sessionId: string; text: string };
|
||||
@@ -66,6 +97,21 @@ type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[]
|
||||
type OnboardingStep = "name" | "birth" | "place" | "rectification";
|
||||
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
|
||||
type AccountDialog = "profile" | "redeem" | "logout";
|
||||
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
|
||||
type DailyStarlanguageApiResponse = {
|
||||
status?: "ok";
|
||||
card?: DailyStarlanguageCard;
|
||||
source?: "calculation_lite";
|
||||
claim_status?: "exploratory_unvalidated";
|
||||
boundary?: "not_deterministic_prediction";
|
||||
};
|
||||
type BirthRectificationPreview = {
|
||||
status?: "ok" | "blocked";
|
||||
candidate_scan?: { start?: string; end?: string; candidate_count?: number };
|
||||
question_count?: number;
|
||||
boundary?: "not_auto_rectified";
|
||||
source?: "active_rectification_questions" | "fallback_unavailable";
|
||||
};
|
||||
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
|
||||
type PendingConsultation = {
|
||||
readonly requestId: string;
|
||||
@@ -111,6 +157,13 @@ const previewModelCatalog = parsePublicModelCatalog({
|
||||
|
||||
const presetOnboardingMessage = "你好,我是 Jyotisha。\n开始前,我想先认识你。\n请问我该怎么称呼你?";
|
||||
|
||||
const dailyStarlanguageCards: DailyStarlanguageCard[] = [
|
||||
{ trend: "先收束,再推进。适合把一个悬而未决的问题拆小。", action: "选一件最重要的事,给它留出 45 分钟不被打断的时间。", caution: "避免在情绪最满时做承诺。" },
|
||||
{ trend: "适合整理关系与边界。越清楚,越不容易被外界节奏带走。", action: "把今天要回复的人和要推迟的事分开列出来。", caution: "不要把暂时的沉默误读成最终答案。" },
|
||||
{ trend: "执行力比灵感更重要。小步完成会比大计划更有力量。", action: "先完成一个可交付版本,再考虑优化。", caution: "别让完美感拖慢开始。" },
|
||||
{ trend: "适合观察资源流向:时间、注意力、金钱都算。", action: "检查一个正在消耗你的习惯,并给它设上限。", caution: "不要为了短期安心做长期成本高的选择。" },
|
||||
];
|
||||
|
||||
const greetingVariants: Record<GreetingPeriod, Array<(name: string) => string>> = {
|
||||
morning: [
|
||||
(name) => `早上好,${name}。今天最想先看什么?`,
|
||||
@@ -210,6 +263,173 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
|
||||
return { label, lat: location.center[1], lon: location.center[0], tz: china.timezone };
|
||||
}
|
||||
|
||||
function chartLibraryStorageKey(accountId: string) {
|
||||
return `jyotisha_chart_library:${accountId}`;
|
||||
}
|
||||
function synastryHistoryStorageKey(accountId: string) {
|
||||
return `jyotisha_synastry_history:${accountId}`;
|
||||
}
|
||||
|
||||
function profileReadyForLibrary(profile: Profile) {
|
||||
return !missingProfileStep(profile);
|
||||
}
|
||||
|
||||
function buildSelfChartRecord(profile: Profile): ChartLibraryRecord {
|
||||
return { id: "self", role: "self", profile, updatedAt: timestamp() };
|
||||
}
|
||||
|
||||
function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
|
||||
if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self");
|
||||
const others = library.filter((record) => record.role !== "self");
|
||||
return [buildSelfChartRecord(profile), ...others];
|
||||
}
|
||||
|
||||
function readChartLibrary(accountId: string): ChartLibraryRecord[] {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[];
|
||||
return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.profile) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
function readSynastryHistory(accountId: string): SynastryReportCard[] {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(synastryHistoryStorageKey(accountId)) || "[]") as SynastryReportCard[];
|
||||
return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.partnerName).slice(0, 10) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeSynastryHistory(accountId: string, history: SynastryReportCard[]) {
|
||||
localStorage.setItem(synastryHistoryStorageKey(accountId), JSON.stringify(history.slice(0, 10)));
|
||||
}
|
||||
|
||||
function normalizeSynastryReportApiRecord(record: SynastryReportApiRecord): SynastryReportCard | null {
|
||||
if (!record.report || typeof record.report !== "object") return null;
|
||||
return {
|
||||
...record.report,
|
||||
id: record.id,
|
||||
partnerName: record.partner_name || record.report.partnerName || "对方",
|
||||
createdAt: Date.parse(record.created_at || "") || record.report.createdAt || timestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCloudSynastryHistory() {
|
||||
const response = await fetch("/api/synastry-reports", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("cloud_synastry_history_unavailable");
|
||||
const payload = await response.json().catch(() => null) as { reports?: SynastryReportApiRecord[] } | null;
|
||||
return (payload?.reports || []).map(normalizeSynastryReportApiRecord).filter(Boolean) as SynastryReportCard[];
|
||||
}
|
||||
|
||||
async function saveCloudSynastryReport(report: SynastryReportCard) {
|
||||
const response = await fetch("/api/synastry-reports", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ partnerName: report.partnerName, report }),
|
||||
});
|
||||
if (!response.ok) throw new Error("cloud_synastry_report_save_failed");
|
||||
const payload = await response.json().catch(() => null) as { report?: SynastryReportApiRecord } | null;
|
||||
return payload?.report ? normalizeSynastryReportApiRecord(payload.report) || report : report;
|
||||
}
|
||||
|
||||
function normalizeChartLibraryApiRecord(record: ChartLibraryApiRecord): ChartLibraryRecord {
|
||||
return {
|
||||
id: record.role === "self" ? "self" : record.id,
|
||||
role: record.role,
|
||||
profile: record.profile,
|
||||
updatedAt: Date.parse(record.updated_at || "") || timestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCloudChartLibrary() {
|
||||
const response = await fetch("/api/chart-profiles", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("cloud_chart_library_unavailable");
|
||||
const payload = await response.json().catch(() => null) as { profiles?: ChartLibraryApiRecord[] } | null;
|
||||
return (payload?.profiles || []).map(normalizeChartLibraryApiRecord);
|
||||
}
|
||||
|
||||
async function saveCloudChartProfile(record: ChartLibraryRecord) {
|
||||
const response = await fetch("/api/chart-profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: record.role === "self" ? undefined : record.id,
|
||||
role: record.role,
|
||||
profile: record.profile,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("cloud_chart_profile_save_failed");
|
||||
const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord } | null;
|
||||
return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record;
|
||||
}
|
||||
|
||||
async function deleteCloudChartProfile(recordId: string) {
|
||||
const response = await fetch(`/api/chart-profiles/${encodeURIComponent(recordId)}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error("cloud_chart_profile_delete_failed");
|
||||
}
|
||||
|
||||
function profilePlaceLabel(profile: Profile) {
|
||||
return selectedBirthPlace(profile)?.label || "地点未完整";
|
||||
}
|
||||
|
||||
function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile) {
|
||||
return [
|
||||
`请用印度占星合盘分析我和${partnerProfile.name || "对方"}的关系。`,
|
||||
`我的资料:${selfProfile.name || "本人"},${selfProfile.date} ${selfProfile.time},${profilePlaceLabel(selfProfile)}。`,
|
||||
`对方资料:${partnerProfile.name || "对方"},${partnerProfile.date} ${partnerProfile.time},${profilePlaceLabel(partnerProfile)}。`,
|
||||
"请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildDailyStarlanguageQuestion(profile: Profile) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return [
|
||||
`请生成今日星语:${today},对象是${profile.name || "我"}。`,
|
||||
`出生资料:${profile.date} ${profile.time},${profilePlaceLabel(profile)}。`,
|
||||
"请输出今日趋势、适合推进的事、需要避开的事、一个行动建议。",
|
||||
"边界:这是探索性日提示,不是确定预测;若涉及精确事件日期,请标为候选触发,不要包装成必然结论。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildDailyStarlanguageCard(profile: Profile) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const seed = `${today}-${profile.date}-${profile.time}-${profile.provinceCode}-${profile.cityCode}`;
|
||||
const index = Array.from(seed).reduce((sum, char) => sum + char.charCodeAt(0), 0) % dailyStarlanguageCards.length;
|
||||
return dailyStarlanguageCards[index];
|
||||
}
|
||||
|
||||
async function fetchDailyStarlanguage(profile: Profile) {
|
||||
const response = await fetch("/api/daily-starlanguage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile }),
|
||||
});
|
||||
if (!response.ok) throw new Error("daily_starlanguage_unavailable");
|
||||
const payload = await response.json().catch(() => null) as DailyStarlanguageApiResponse | null;
|
||||
if (payload?.status !== "ok" || !payload.card) throw new Error("daily_starlanguage_invalid");
|
||||
return payload.card;
|
||||
}
|
||||
|
||||
async function fetchBirthRectificationPreview(profile: Profile) {
|
||||
const response = await fetch("/api/birth-rectification", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile }),
|
||||
});
|
||||
if (!response.ok) throw new Error("birth_rectification_preview_unavailable");
|
||||
return await response.json().catch(() => null) as BirthRectificationPreview | null;
|
||||
}
|
||||
|
||||
function buildBirthTimeRectificationQuestion(profile: Profile) {
|
||||
return [
|
||||
`请为${profile.name || "我"}做生时校正辅助。`,
|
||||
`当前记录:${profile.date} ${profile.time || "时间不确定"},${profilePlaceLabel(profile)}。`,
|
||||
"请先列出需要我补充的关键人生事件,再给候选出生时间段、每段会影响的 Lagna/分盘/大运差异。",
|
||||
"边界:候选出生时间段必须标为待验证,不能直接改写默认星盘;没有事件证据前不要声称校正完成。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function missingProfileStep(profile: Profile): OnboardingStep | null {
|
||||
if (!profile.name.trim()) return "name";
|
||||
if (!isBirthTimeDraftReady(profile)) return "birth";
|
||||
@@ -398,12 +618,12 @@ function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (p
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
|
||||
function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) {
|
||||
return (
|
||||
<>
|
||||
<label>
|
||||
<span>如何称呼你</span>
|
||||
<input id="profile-name" required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
|
||||
<input id={nameInputId} required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
|
||||
</label>
|
||||
<BirthTimeIntakeFields value={value} onPatch={(patch) => onChange({ ...value, ...patch })} />
|
||||
<BirthLocationFields value={value} onChange={onChange} />
|
||||
@@ -499,6 +719,13 @@ export default function Home() {
|
||||
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
|
||||
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
|
||||
const [chartLibraryOpen, setChartLibraryOpen] = useState(false);
|
||||
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
|
||||
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
|
||||
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
|
||||
const [dailyStarlanguageCard, setDailyStarlanguageCard] = useState<DailyStarlanguageCard | null>(null);
|
||||
const [birthRectificationPreview, setBirthRectificationPreview] = useState<BirthRectificationPreview | null>(null);
|
||||
const [profileNotice, setProfileNotice] = useState("");
|
||||
const [account, setAccount] = useState<Account | null>(null);
|
||||
const [accountError, setAccountError] = useState("");
|
||||
@@ -508,6 +735,10 @@ export default function Home() {
|
||||
const [redeeming, setRedeeming] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
|
||||
const [archivedSessionIds, setArchivedSessionIds] = useState<string[]>([]);
|
||||
const [showArchivedSessions, setShowArchivedSessions] = useState(false);
|
||||
const [sessionMenuId, setSessionMenuId] = useState<string | null>(null);
|
||||
const [modelCatalog, setModelCatalog] = useState<PublicLanguageModelCatalog | null>(null);
|
||||
const [activeSessionId, setActiveSessionId] = useState("");
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -547,6 +778,7 @@ export default function Home() {
|
||||
const modelSyncFailures = useRef(new Set<string>());
|
||||
const modelSelectionVersions = useRef(new Map<string, number>());
|
||||
const activeSessionIdRef = useRef("");
|
||||
const chartLibraryLoadedAccount = useRef("");
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(null);
|
||||
const birthTimeRevisionPending = useRef(false);
|
||||
@@ -559,16 +791,98 @@ export default function Home() {
|
||||
});
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
|
||||
const visibleSessions = sessions
|
||||
.filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id))
|
||||
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
|
||||
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
|
||||
const isLoading = pendingSessionId === activeSession?.id;
|
||||
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
|
||||
const accountId = account?.user.id;
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId) return;
|
||||
const prefix = `jyotisha-session-controls:${accountId}:`;
|
||||
setPinnedSessionIds(JSON.parse(localStorage.getItem(`${prefix}pinned`) || "[]"));
|
||||
setArchivedSessionIds(JSON.parse(localStorage.getItem(`${prefix}archived`) || "[]"));
|
||||
}, [accountId, hydrated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !accountId) return;
|
||||
const prefix = `jyotisha-session-controls:${accountId}:`;
|
||||
localStorage.setItem(`${prefix}pinned`, JSON.stringify(pinnedSessionIds));
|
||||
localStorage.setItem(`${prefix}archived`, JSON.stringify(archivedSessionIds));
|
||||
}, [accountId, archivedSessionIds, hydrated, pinnedSessionIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionMenuId) return;
|
||||
function closeSessionMenu(event: Event) {
|
||||
if (event instanceof globalThis.KeyboardEvent && event.key !== "Escape") return;
|
||||
if (event instanceof MouseEvent && (event.target as Element | null)?.closest(".session-row")) return;
|
||||
setSessionMenuId(null);
|
||||
}
|
||||
window.addEventListener("mousedown", closeSessionMenu);
|
||||
window.addEventListener("keydown", closeSessionMenu);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", closeSessionMenu);
|
||||
window.removeEventListener("keydown", closeSessionMenu);
|
||||
};
|
||||
}, [sessionMenuId]);
|
||||
const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? [];
|
||||
const accountId = account?.user.id;
|
||||
useEffect(() => {
|
||||
if (!accountId) {
|
||||
setChartLibrary([]);
|
||||
setSynastryHistory([]);
|
||||
chartLibraryLoadedAccount.current = "";
|
||||
return;
|
||||
}
|
||||
if (chartLibraryLoadedAccount.current === accountId) return;
|
||||
chartLibraryLoadedAccount.current = accountId;
|
||||
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile));
|
||||
setSynastryHistory(readSynastryHistory(accountId));
|
||||
void fetchCloudChartLibrary()
|
||||
.then((cloudLibrary) => {
|
||||
setChartLibrary((current) => {
|
||||
const otherById = new Map([
|
||||
...current.filter((record) => record.role === "other").map((record) => [record.id, record] as const),
|
||||
...cloudLibrary.filter((record) => record.role === "other").map((record) => [record.id, record] as const),
|
||||
]);
|
||||
const next = upsertSelfChart([...otherById.values()], profile);
|
||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Cloud chart library is best-effort; local library remains usable.
|
||||
});
|
||||
void fetchCloudSynastryHistory()
|
||||
.then((cloudHistory) => {
|
||||
setSynastryHistory((current) => {
|
||||
const byId = new Map([...current, ...cloudHistory].map((record) => [record.id, record] as const));
|
||||
const next = [...byId.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 10);
|
||||
writeSynastryHistory(accountId, next);
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Cloud synastry history is best-effort; local history remains usable.
|
||||
});
|
||||
}, [accountId, profile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) return;
|
||||
setChartLibrary((current) => {
|
||||
const next = upsertSelfChart(current, profile);
|
||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}, [accountId, profile]);
|
||||
|
||||
const profileComplete = isProfileComplete(profile);
|
||||
const dailyStarlanguage = dailyStarlanguageCard ?? (profileComplete ? buildDailyStarlanguageCard(profile) : null);
|
||||
const onboardingPending = profileComplete && !onboarding && !onboardingError;
|
||||
const currentOnboardingMessage = onboardingJustCompleted
|
||||
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
|
||||
@@ -837,6 +1151,38 @@ export default function Home() {
|
||||
};
|
||||
}, [accountId, hydrated, onboarding, onboardingError, profile.name, profileComplete, startGreeting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !profileComplete) return;
|
||||
let cancelled = false;
|
||||
setDailyStarlanguageCard(null);
|
||||
void fetchDailyStarlanguage(profile)
|
||||
.then((card) => {
|
||||
if (!cancelled) setDailyStarlanguageCard(card);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDailyStarlanguageCard(buildDailyStarlanguageCard(profile));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !profileComplete) return;
|
||||
let cancelled = false;
|
||||
setBirthRectificationPreview(null);
|
||||
void fetchBirthRectificationPreview(profile)
|
||||
.then((preview) => {
|
||||
if (!cancelled) setBirthRectificationPreview(preview);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBirthRectificationPreview({ status: "blocked", boundary: "not_auto_rectified", source: "fallback_unavailable" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hydrated, profile.date, profile.time, profile.provinceCode, profile.cityCode, profileComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
conversationEnd.current?.scrollIntoView({ behavior: isLoading || reduceMotion ? "auto" : "smooth", block: "end" });
|
||||
@@ -912,6 +1258,70 @@ export default function Home() {
|
||||
if (insertError) throw new Error(`云端同步失败:${insertError.message}`);
|
||||
}
|
||||
|
||||
async function renameSession(session: ChatSession) {
|
||||
const title = window.prompt("重命名聊天记录", session.title)?.trim();
|
||||
if (!title || title === session.title) return;
|
||||
const nextSession = { ...session, title, updatedAt: timestamp() };
|
||||
updateSession(session.id, () => nextSession);
|
||||
try {
|
||||
await persistSession(nextSession);
|
||||
} catch (caught) {
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "重命名同步失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(session: ChatSession) {
|
||||
if (!account || !window.confirm(`删除“${session.title}”?此操作不可恢复。`)) return;
|
||||
const previousSessions = sessions;
|
||||
const nextSessions = sessions.filter((item) => item.id !== session.id);
|
||||
setSessions(nextSessions);
|
||||
setPinnedSessionIds((current) => current.filter((id) => id !== session.id));
|
||||
setArchivedSessionIds((current) => current.filter((id) => id !== session.id));
|
||||
if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? "");
|
||||
try {
|
||||
const supabase = createBrowserSupabaseClient();
|
||||
const { error } = await supabase.from("chat_sessions").delete().eq("id", session.id).eq("user_id", account.user.id);
|
||||
if (error) throw error;
|
||||
} catch (caught) {
|
||||
setSessions(previousSessions);
|
||||
setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
function togglePinnedSession(sessionId: string) {
|
||||
setPinnedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]);
|
||||
}
|
||||
|
||||
function toggleArchivedSession(sessionId: string) {
|
||||
setArchivedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]);
|
||||
if (activeSessionId === sessionId) setActiveSessionId(visibleSessions.find((session) => session.id !== sessionId)?.id ?? "");
|
||||
}
|
||||
|
||||
async function shareSession(session: ChatSession) {
|
||||
const sharePayload = {
|
||||
share_payload_version: 1,
|
||||
exported_at: new Date().toISOString(),
|
||||
title: session.title,
|
||||
theme: session.theme,
|
||||
message_count: session.messages.length,
|
||||
messages: session.messages.map((message) => ({ role: message.role, text: message.text })),
|
||||
};
|
||||
const transcript = [
|
||||
`Jyotisha 对话:${session.title}`,
|
||||
"",
|
||||
...session.messages.map((message) => `${message.role === "user" ? "我" : "Jyotisha"}:${message.text}`),
|
||||
"",
|
||||
"---- JSON 分享包 ----",
|
||||
JSON.stringify(sharePayload, null, 2),
|
||||
].join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(transcript);
|
||||
setComposerNotice("已复制当前聊天,可粘贴转发。");
|
||||
} catch {
|
||||
setComposerNotice("无法访问剪贴板,请手动复制聊天内容。");
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
if (!account || !modelCatalog || creatingSession) return;
|
||||
const nextSession = createSession(modelCatalog.defaultModelId);
|
||||
@@ -1029,9 +1439,11 @@ export default function Home() {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
|
||||
const birthPlace = selectedBirthPlace(nextProfile);
|
||||
const { data, error } = await createBrowserSupabaseClient()
|
||||
.from("profiles")
|
||||
.update({
|
||||
const response = await fetch("/api/account", {
|
||||
method: "PATCH",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: nextProfile.name.trim() || null,
|
||||
birth_date: nextProfile.date || null,
|
||||
...birthTimePersistenceValues(nextProfile),
|
||||
@@ -1042,13 +1454,70 @@ export default function Home() {
|
||||
latitude: birthPlace?.lat ?? null,
|
||||
longitude: birthPlace?.lon ?? null,
|
||||
timezone_offset: birthPlace?.tz ?? null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", account.user.id)
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) throw new Error("账户档案不存在,请重新登录后再试。");
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(payload?.error || "账户资料暂时无法保存。");
|
||||
}
|
||||
await saveCloudChartProfile({ ...buildSelfChartRecord(nextProfile), updatedAt: timestamp() }).catch(() => null);
|
||||
}
|
||||
|
||||
async function saveOtherChart(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() };
|
||||
if (missingProfileStep(nextProfile)) {
|
||||
setAccountError("请补全其他星盘的称呼、出生时间和出生地点。");
|
||||
return;
|
||||
}
|
||||
if (!accountId) return;
|
||||
let record: ChartLibraryRecord = {
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
role: "other",
|
||||
profile: nextProfile,
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
try {
|
||||
record = await saveCloudChartProfile(record);
|
||||
} catch {
|
||||
// Keep local chart library usable when cloud sync is unavailable.
|
||||
}
|
||||
setChartLibrary((current) => {
|
||||
const next = [...upsertSelfChart(current, profile), record];
|
||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
setOtherProfileDraft(emptyProfile);
|
||||
setAccountError("");
|
||||
setProfileNotice("已添加到星盘库。");
|
||||
}
|
||||
|
||||
function deleteOtherChart(recordId: string) {
|
||||
if (!accountId) return;
|
||||
void deleteCloudChartProfile(recordId).catch(() => {
|
||||
// Local deletion should not be blocked by temporary cloud sync failures.
|
||||
});
|
||||
setChartLibrary((current) => {
|
||||
const next = current.filter((record) => record.id !== recordId || record.role === "self");
|
||||
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function makeDefaultChart(record: ChartLibraryRecord) {
|
||||
if (record.role !== "other" || profileSaving) return;
|
||||
setProfileSaving(true);
|
||||
setAccountError("");
|
||||
try {
|
||||
await persistProfile(record.profile);
|
||||
setProfile(record.profile);
|
||||
setProfileDraft(record.profile);
|
||||
setProfileNotice("已设为当前默认星盘。");
|
||||
} catch (caught) {
|
||||
setAccountError(friendlyError(caught instanceof Error ? caught.message : "默认星盘保存失败"));
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function assessSavedBirthTime(nextProfile: Profile) {
|
||||
@@ -1255,6 +1724,75 @@ export default function Home() {
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus());
|
||||
}
|
||||
|
||||
function draftDailyStarlanguageQuestion() {
|
||||
chooseSuggestedQuestion(buildDailyStarlanguageQuestion(profile), "timing");
|
||||
}
|
||||
|
||||
function draftBirthTimeRectificationQuestion() {
|
||||
chooseSuggestedQuestion(buildBirthTimeRectificationQuestion(profile), "timing");
|
||||
}
|
||||
|
||||
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord) {
|
||||
if (record.role !== "other") return;
|
||||
const baseQuestion = buildSynastryQuestion(profile, record.profile);
|
||||
try {
|
||||
const response = await fetch("/api/synastry", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile }),
|
||||
});
|
||||
const payload = await response.json().catch(() => null) as { status?: string; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null;
|
||||
if (response.ok && payload?.status === "ok") {
|
||||
const score = payload.synastry?.total_score;
|
||||
const max = payload.synastry?.max_score;
|
||||
const assessment = payload.synastry?.assessment;
|
||||
const layers = (payload.evidenceLayers || []).join(" / ") || "Ashtakoot / Moon / D9";
|
||||
const reportCard: SynastryReportCard = {
|
||||
id: `${record.id}-${Date.now()}`,
|
||||
partnerName: record.profile.name || "对方",
|
||||
score,
|
||||
maxScore: max,
|
||||
assessment,
|
||||
headline: payload.relationshipReport?.headline,
|
||||
scoreBand: payload.relationshipReport?.scoreBand,
|
||||
strengths: payload.relationshipReport?.strengths,
|
||||
risks: payload.relationshipReport?.risks,
|
||||
nextEvidence: payload.relationshipReport?.nextEvidence,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
let savedReportCard = reportCard;
|
||||
if (accountId) {
|
||||
try {
|
||||
savedReportCard = await saveCloudSynastryReport(reportCard);
|
||||
} catch {
|
||||
// Local history remains the fallback when cloud persistence is unavailable.
|
||||
}
|
||||
}
|
||||
setSynastryReportCard(savedReportCard);
|
||||
if (accountId) {
|
||||
setSynastryHistory((current) => {
|
||||
const next = [savedReportCard, ...current.filter((item) => item.id !== savedReportCard.id)].slice(0, 10);
|
||||
writeSynastryHistory(accountId, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
chooseSuggestedQuestion([
|
||||
baseQuestion,
|
||||
"",
|
||||
`已计算基础合盘证据:${layers};Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`,
|
||||
payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "",
|
||||
].join("\n"), "marriage");
|
||||
} else {
|
||||
chooseSuggestedQuestion(baseQuestion, "marriage");
|
||||
setComposerNotice(payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。");
|
||||
}
|
||||
} catch {
|
||||
chooseSuggestedQuestion(baseQuestion, "marriage");
|
||||
setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。");
|
||||
}
|
||||
closeAccountDialog();
|
||||
}
|
||||
|
||||
async function requestCancellation(requestId: string) {
|
||||
const existing = cancellationRequests.current.get(requestId);
|
||||
if (existing) return existing;
|
||||
@@ -1690,10 +2228,12 @@ export default function Home() {
|
||||
|| "你",
|
||||
};
|
||||
|
||||
const sidebarSessions = sessions.map((session) => ({
|
||||
const sidebarSessions = visibleSessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
messageCount: session.messages.length,
|
||||
pinned: pinnedSessionIds.includes(session.id),
|
||||
archived: archivedSessionIds.includes(session.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -1707,6 +2247,31 @@ export default function Home() {
|
||||
accountTriggerRef={accountTrigger}
|
||||
newChatDisabled={!hydrated || !modelCatalog || creatingSession || Boolean(pendingSessionId) || cancellationPending}
|
||||
creatingSession={creatingSession}
|
||||
sessionControls={{
|
||||
archivedCount: archivedSessionIds.length,
|
||||
showingArchived: showArchivedSessions,
|
||||
menuSessionId: sessionMenuId,
|
||||
disabled: Boolean(pendingSessionId) || cancellationPending,
|
||||
onToggleArchivedView: () => {
|
||||
setShowArchivedSessions((current) => !current);
|
||||
setSessionMenuId(null);
|
||||
},
|
||||
onMenuSessionChange: setSessionMenuId,
|
||||
onTogglePinned: togglePinnedSession,
|
||||
onRename: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void renameSession(session);
|
||||
},
|
||||
onShare: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void shareSession(session);
|
||||
},
|
||||
onToggleArchived: toggleArchivedSession,
|
||||
onDelete: (sessionId) => {
|
||||
const session = sessions.find((candidate) => candidate.id === sessionId);
|
||||
if (session) void deleteSession(session);
|
||||
},
|
||||
}}
|
||||
onAccountMenuOpenChange={setAccountMenuOpen}
|
||||
onNewChat={() => void startNewChat()}
|
||||
onSelectSession={selectSession}
|
||||
@@ -1805,6 +2370,32 @@ export default function Home() {
|
||||
<div className="starter-loading" role="status">正在准备三个入门问题…</div>
|
||||
) : (
|
||||
<div className="starter-list" aria-label="Jyotisha 推荐的初始问题">
|
||||
<div className="product-entrypoints" aria-label="常用占星入口">
|
||||
<article className="daily-starlanguage-card" aria-label="今日星语">
|
||||
<div className="daily-starlanguage-heading">
|
||||
<span>今日星语</span>
|
||||
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftDailyStarlanguageQuestion}>深入看今日 <ArrowUpRight className="starter-arrow" aria-hidden="true" /></button>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>今日趋势</dt><dd>{dailyStarlanguage?.trend}</dd></div>
|
||||
<div><dt>行动建议</dt><dd>{dailyStarlanguage?.action}</dd></div>
|
||||
<div><dt>今日提醒</dt><dd>{dailyStarlanguage?.caution}</dd></div>
|
||||
</dl>
|
||||
<small>探索性日提示,不是确定预测。</small>
|
||||
</article>
|
||||
<article className="birth-rectification-card" aria-label="生时校正">
|
||||
<div className="daily-starlanguage-heading">
|
||||
<span>生时校正</span>
|
||||
<button type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog} onClick={draftBirthTimeRectificationQuestion}>开始校正 <ArrowUpRight className="starter-arrow" aria-hidden="true" /></button>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>候选出生时间段</dt><dd>{birthRectificationPreview?.candidate_scan?.start && birthRectificationPreview?.candidate_scan?.end ? `${birthRectificationPreview.candidate_scan.start} – ${birthRectificationPreview.candidate_scan.end}` : "默认先扫描前后 30 分钟"}</dd></div>
|
||||
<div><dt>候选点</dt><dd>{birthRectificationPreview?.candidate_scan?.candidate_count ? `${birthRectificationPreview.candidate_scan.candidate_count} 个` : "待后端生成"}</dd></div>
|
||||
<div><dt>问题数</dt><dd>{birthRectificationPreview?.question_count ? `${birthRectificationPreview.question_count} 个事件问题` : "需补关键人生事件"}</dd></div>
|
||||
</dl>
|
||||
<small>不能直接改写默认星盘;需事件证据验证。</small>
|
||||
</article>
|
||||
</div>
|
||||
{(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item) => {
|
||||
const theme = themes.find((candidate) => candidate.id === item.theme);
|
||||
return (
|
||||
@@ -1924,10 +2515,88 @@ export default function Home() {
|
||||
{activeAccountDialog === "profile" && (
|
||||
<>
|
||||
{accountError && <p className="form-error" role="alert">{accountError}</p>}
|
||||
<section className="sheet-section birth-section">
|
||||
<div className="section-heading"><b>出生资料</b><small>加密传输并保存到云端,用于此账号的所有对话</small></div>
|
||||
<div className="default-chart-card" aria-label="当前默认星盘">
|
||||
<div>
|
||||
<span>当前默认星盘</span>
|
||||
<strong>{profileDraft.name.trim() || "未命名"}</strong>
|
||||
<small>角色:本人</small>
|
||||
</div>
|
||||
<button className="button-secondary" type="button" onClick={() => setChartLibraryOpen((current) => !current)}>管理星盘库</button>
|
||||
</div>
|
||||
{chartLibraryOpen && (
|
||||
<div className="chart-library-panel" aria-label="星盘库">
|
||||
<div className="chart-library-group">
|
||||
<b>本人</b>
|
||||
{chartLibrary.filter((record) => record.role === "self").map((record) => (
|
||||
<article className="chart-library-item" key={record.id}>
|
||||
<div>
|
||||
<strong>{record.profile.name || "未命名"}</strong>
|
||||
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
|
||||
</div>
|
||||
<span>当前默认</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="chart-library-group">
|
||||
<b>其他</b>
|
||||
{chartLibrary.filter((record) => record.role === "other").length === 0 && <p className="empty-library-copy">还没有其他星盘。</p>}
|
||||
{chartLibrary.filter((record) => record.role === "other").map((record) => (
|
||||
<article className="chart-library-item" key={record.id}>
|
||||
<div>
|
||||
<strong>{record.profile.name || "未命名"}</strong>
|
||||
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
|
||||
</div>
|
||||
<div className="chart-library-actions">
|
||||
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record)}>用于合盘</button>
|
||||
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}>设为默认</button>
|
||||
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}>删除</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{synastryReportCard && (
|
||||
<article className="synastry-report-card" aria-label="合盘结果摘要">
|
||||
<div>
|
||||
<span>合盘结果摘要</span>
|
||||
<strong>{synastryReportCard.partnerName}</strong>
|
||||
<small>Ashtakoot {synastryReportCard.score ?? "?"}/{synastryReportCard.maxScore ?? "?"} · {synastryReportCard.assessment || synastryReportCard.scoreBand || "待解释"}</small>
|
||||
</div>
|
||||
{synastryReportCard.headline && <p>{synastryReportCard.headline}</p>}
|
||||
<details>
|
||||
<summary>查看证据</summary>
|
||||
<ul>
|
||||
{(synastryReportCard.strengths || []).map((item) => <li key={item}>{item}</li>)}
|
||||
{(synastryReportCard.risks || []).map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
<small>下一步证据:{(synastryReportCard.nextEvidence || []).join(" / ") || "双方 Dasha / UL-DK / D9 7宫"}</small>
|
||||
</details>
|
||||
</article>
|
||||
)}
|
||||
{synastryHistory.length > 0 && (
|
||||
<div className="synastry-history-list" aria-label="合盘历史">
|
||||
<b>合盘历史</b>
|
||||
{synastryHistory.slice(0, 5).map((item) => (
|
||||
<button key={item.id} type="button" className="synastry-history-item" onClick={() => setSynastryReportCard(item)}>
|
||||
<span>{item.partnerName}</span>
|
||||
<small>Ashtakoot {item.score ?? "?"}/{item.maxScore ?? "?"} · {item.assessment || item.scoreBand || "待解释"}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
|
||||
<div className="section-heading"><b>添加其他星盘</b><small>用于合盘、亲友盘或客户盘。</small></div>
|
||||
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
|
||||
<button className="button-primary save-profile" type="submit" disabled={!account}>添加到星盘库</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
|
||||
<form className="profile-form" onSubmit={saveProfile}>
|
||||
<ProfileFields value={profileDraft} onChange={setProfileDraft} />
|
||||
<button className="button-primary save-profile" type="submit" disabled={profileSaving}>{profileSaving ? "保存中" : "保存出生资料"}</button>
|
||||
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
|
||||
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user