// GenQi Sabah Intake App - Single-file MVP
// 技术:React + Tailwind + supabase-js(浏览器端使用 anon key)
// 用途:
// 1) 公网顾客自助填写问诊表(/intake)
// 2) 店内员工使用 Admin 区域(登录后)可搜索/查看/追踪顾客档案
// 3) 可新增回访记录(Visits)与上传舌象/脉象图片(Supabase Storage)
//
// ⚙️ 上线前配置(必读):
// - 新建 Supabase Project,拷贝 Project URL 与 anon 公钥到 .env(或临时粘贴至下方 env 占位)
// - SQL 建表与 RLS(复制到 Supabase SQL Editor 运行):
// -------------------------------------------------------------
// -- schema: public
// create table if not exists customers (
// id uuid primary key default gen_random_uuid(),
// full_name text not null,
// phone text,
// gender text,
// age int,
// created_at timestamp with time zone default now(),
// updated_at timestamp with time zone default now()
// );
// create table if not exists visits (
// id uuid primary key default gen_random_uuid(),
// customer_id uuid references customers(id) on delete cascade,
// visit_date date default current_date,
// symptoms text,
// bowel text,
// urine text,
// sleep text,
// thirst text,
// tongue text,
// pulse text,
// body_feel text,
// menstrual text,
// notes text,
// created_at timestamp with time zone default now()
// );
// -- 开启 RLS
// alter table customers enable row level security;
// alter table visits enable row level security;
// -- 匿名可插入数据(顾客自助提交)
// create policy "anon_insert_customers" on customers
// for insert to anon with check (true);
// create policy "anon_insert_visits" on visits
// for insert to anon with check (true);
// -- 登录用户可读写全部(店内员工账号)
// create policy "auth_full_access_customers" on customers
// for all to authenticated using (true) with check (true);
// create policy "auth_full_access_visits" on visits
// for all to authenticated using (true) with check (true);
// -------------------------------------------------------------
// - Storage: 建 bucket name = "uploads",Public
// - Auth: 启用 Email/Password,创建店员账户(如 admin@sabah.genqi.vip )
// - 部署:推荐 Vercel,绑定自定义域 sabah.genqi.vip(DNS CNAME 到 Vercel)
// ----------------------------------------------
import React, { useEffect, useMemo, useState } from "react";
import { createClient } from "@supabase/supabase-js";
// ====== 1) 环境变量占位(部署到 Vercel 时使用环境变量,不要写死) ======
const SUPABASE_URL = (globalThis as any).env?.SUPABASE_URL || "https://YOUR-PROJECT.supabase.co";
const SUPABASE_ANON_KEY = (globalThis as any).env?.SUPABASE_ANON_KEY || "YOUR-ANON-KEY";
// ====== 2) 初始化 Supabase 客户端 ======
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// ====== 3) UI 小组件 ======
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
{title}
{children}
);
}
function Input({ label, ...props }: React.InputHTMLAttributes & { label?: string }) {
return (
);
}
function Textarea({ label, ...props }: React.TextareaHTMLAttributes & { label?: string }) {
return (
);
}
// ====== 4) Intake 表单(公网) ======
function IntakeForm({ onSaved }: { onSaved?: (payload: { customerId: string; visitId: string }) => void }) {
const [form, setForm] = useState({
full_name: "",
phone: "",
gender: "",
age: "",
symptoms: "",
bowel: "",
urine: "",
sleep: "",
thirst: "",
tongue: "",
pulse: "",
body_feel: "",
menstrual: "",
notes: "",
});
const [submitting, setSubmitting] = useState(false);
const [ok, setOk] = useState(null);
const [err, setErr] = useState(null);
const update = (k: string, v: string) => setForm((s) => ({ ...s, [k]: v }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setOk(null);
setErr(null);
try {
if (!form.full_name) throw new Error("请填写姓名");
// 1) 建立/插入 customers
const { data: cust, error: e1 } = await supabase
.from("customers")
.insert({
full_name: form.full_name,
phone: form.phone,
gender: form.gender,
age: form.age ? Number(form.age) : null,
})
.select()
.single();
if (e1) throw e1;
// 2) 插入 visit 首次问诊
const { data: visit, error: e2 } = await supabase
.from("visits")
.insert({
customer_id: cust.id,
symptoms: form.symptoms,
bowel: form.bowel,
urine: form.urine,
sleep: form.sleep,
thirst: form.thirst,
tongue: form.tongue,
pulse: form.pulse,
body_feel: form.body_feel,
menstrual: form.menstrual,
notes: form.notes,
})
.select()
.single();
if (e2) throw e2;
setOk("提交成功,我们已收到您的资料!");
onSaved?.({ customerId: cust.id, visitId: visit.id });
setForm({
full_name: "",
phone: "",
gender: "",
age: "",
symptoms: "",
bowel: "",
urine: "",
sleep: "",
thirst: "",
tongue: "",
pulse: "",
body_feel: "",
menstrual: "",
notes: "",
});
} catch (e: any) {
setErr(e?.message || String(e));
} finally {
setSubmitting(false);
}
};
return (
);
}
// ====== 5) Admin(店内)登录 + 搜索/查看 ======
function AdminPanel() {
const [session, setSession] = useState(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [search, setSearch] = useState("");
const [rows, setRows] = useState([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
const { data: sub } = supabase.auth.onAuthStateChange((_evt, sess) => setSession(sess));
supabase.auth.getSession().then(({ data }) => setSession(data.session));
return () => sub.subscription.unsubscribe();
}, []);
const login = async () => {
setBusy(true);
const { error } = await supabase.auth.signInWithPassword({ email, password });
setBusy(false);
if (error) alert(error.message);
};
const logout = async () => {
await supabase.auth.signOut();
};
const runSearch = async () => {
setBusy(true);
try {
let q = supabase.from("customers").select("*, visits(id, visit_date, symptoms, notes)").order("updated_at", { ascending: false });
if (search.trim()) {
// 简单模糊:姓名/电话
q = q.or(`full_name.ilike.%${search}%,phone.ilike.%${search}%`);
}
const { data, error } = await q.limit(100);
if (error) throw error;
setRows(data || []);
} catch (e: any) {
alert(e.message || String(e));
} finally {
setBusy(false);
}
};
const [newVisit, setNewVisit] = useState({ symptoms: "", notes: "" });
const addVisit = async (customerId: string) => {
if (!newVisit.symptoms && !newVisit.notes) return;
const { error } = await supabase.from("visits").insert({ customer_id: customerId, symptoms: newVisit.symptoms, notes: newVisit.notes });
if (error) return alert(error.message);
setNewVisit({ symptoms: "", notes: "" });
runSearch();
};
if (!session) {
return (
);
}
return (
setSearch(e.target.value)} />
{rows.map((c) => (
{c.full_name} #{c.id.slice(0,8)}
{c.phone} • {c.gender || "-"} • {c.age ? `${c.age}岁` : "年龄-"}
建档:{new Date(c.created_at).toLocaleString()}
新增回访记录
历史就诊
{(c.visits || []).map((v: any) => (
{v.visit_date} • 记录#{v.id.slice(0,6)}
{v.symptoms &&
症状:{v.symptoms}
}
{v.notes &&
备注:{v.notes}
}
))}
))}
);
}
// ====== 6) 文件上传(舌象/脉象)演示组件 ======
function UploadDemo() {
const [file, setFile] = useState(null);
const [url, setUrl] = useState(null);
const [customerId, setCustomerId] = useState("");
const upload = async () => {
if (!file || !customerId) return alert("请选择客户并选择图片");
const ext = file.name.split(".").pop();
const path = `${customerId}/${Date.now()}.${ext}`;
const { error } = await supabase.storage.from("uploads").upload(path, file, { upsert: true });
if (error) return alert(error.message);
const { data } = supabase.storage.from("uploads").getPublicUrl(path);
setUrl(data.publicUrl);
};
return (
);
}
// ====== 7) 顶层 App ======
export default function App() {
const [tab, setTab] = useState<'intake' | 'admin' | 'upload'>('intake');
return (
{tab === 'intake' && }
{tab === 'admin' && }
{tab === 'upload' && }
);
}