feat(login): 登录改造(去账密/短信+扫码) + 资讯发布时间/来源/导读表单

- 登录页移除账号密码 tab,仅短信+小程序扫码;扫码组件重写:倒计时+失效模糊蒙版+手动刷新(失效即停轮询,绝不自动刷新)
- 文章编辑器:发布时间(datetime-local,定时发布)+来源+导读(独立编辑不限字数)+作者留空显示官方
- 园区端发布:来源+发布时间(禁早于3天前校验),无发布人输入
This commit is contained in:
Pine
2026-08-28 17:26:29 +08:00
parent 1ed474a006
commit 21c4701de9
3 changed files with 150 additions and 79 deletions
+53 -7
View File
@@ -17,11 +17,12 @@ const CATS = [
{ value: "dynamic", label: "动态" },
];
function stripHtml(html: string): string {
const div = document.createElement("div");
div.innerHTML = html || "";
return (div.textContent || "").replace(/\s+/g, " ").trim();
}
/* 发布者身份徽章(存 source;C 端按此显示 官方/园区/OPC 徽章) */
const PUB_TYPES = [
{ value: "official", label: "官方" },
{ value: "carrier", label: "园区" },
{ value: "opc", label: "OPC" },
];
const uploadImage = async (file: File, insertFn: (url: string, alt: string, href: string) => void) => {
try { const url = await contentApi.upload(file); insertFn(url, "", ""); }
@@ -41,6 +42,9 @@ export default function ArticleEditor() {
const [cat, setCat] = React.useState("policy");
const [title, setTitle] = React.useState("");
const [author, setAuthor] = React.useState("");
const [publishAt, setPublishAt] = React.useState(""); // 发布时间(可未来=定时发布)
const [pubType, setPubType] = React.useState("official"); // 发布者身份(官方/园区/OPC)
const [summary, setSummary] = React.useState(""); // 导读(独立编辑)
const [cover, setCover] = React.useState(""); // 加宽封面 3.35:1
const [coverSmall, setCoverSmall] = React.useState(""); // 小封面 1:1(可选)
const [video, setVideo] = React.useState("");
@@ -60,12 +64,31 @@ export default function ArticleEditor() {
const toolbarConfig: Partial<IToolbarConfig> = { excludeKeys: ["group-video"] };
const wordCount = editor ? editor.getText().replace(/\s/g, "").length : 0;
// ISO(UTC) → datetime-local 值;空返回空
function toLocalInput(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
// datetime-local → ISO(UTC);空返回空
function localToIso(v: string): string {
if (!v) return "";
const d = new Date(v);
return isNaN(d.getTime()) ? "" : d.toISOString();
}
React.useEffect(() => {
if (!id) return;
contentApi.getOne(id).then((r) => {
setCat(r.type || "policy"); setTitle(r.title || ""); setAuthor(r.publisher_name || "");
setCover(r.cover || ""); setCoverSmall(r.cover_small || ""); setVideo(r.video || ""); setLink(r.link || ""); setHtml(r.body || "");
setCardMode(r.card_mode || "small");
setPublishAt(toLocalInput(r.published_at || ""));
// 身份回填:识别 official/carrier/opc;旧数据 operator/其它 → 官方(carrier 保留园区)
setPubType(["official", "carrier", "opc"].includes(r.source || "") ? (r.source as string) : r.source === "carrier" ? "carrier" : "official");
setSummary(r.summary || "");
}).catch((e) => setErr((e as Error).message || "加载失败"));
return () => { if (editor) editor.destroy(); };
}, [id]);
@@ -86,9 +109,11 @@ export default function ArticleEditor() {
type: cat, title, body: html,
cover, cover_small: coverSmall, video, link,
publisher_name: author,
published_at: localToIso(publishAt),
source: pubType,
card_mode: cardMode,
status,
summary: stripHtml(html).slice(0, 80),
summary,
};
const r = savedId ? await contentApi.update(savedId, body) : await contentApi.create(body);
if (!savedId) setSavedId((r as { id: string }).id);
@@ -153,7 +178,28 @@ export default function ArticleEditor() {
onInput={(e) => { const t = e.currentTarget; t.style.height = "auto"; t.style.height = t.scrollHeight + "px"; }}
/>
<div className="pointer-events-none float-right -mt-7 text-xs text-muted-foreground">{title.length}/64</div>
<Input className="mt-1 border-none px-0 text-base" placeholder="请输入作者" value={author} onChange={(e) => setAuthor(e.target.value)} />
<Input className="mt-1 border-none px-0 text-base" placeholder="请输入作者(留空显示「官方」)" value={author} onChange={(e) => setAuthor(e.target.value)} />
<div className="mt-2 flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<label className="flex items-center gap-2">
<input type="datetime-local" className="h-8 rounded-md border bg-transparent px-2 text-sm" value={publishAt} onChange={(e) => setPublishAt(e.target.value)} />
<span className="text-xs"></span>
</label>
<label className="flex items-center gap-2">
<Select value={pubType} onValueChange={setPubType}>
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
<SelectContent>{PUB_TYPES.map((t) => <SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>)}</SelectContent>
</Select>
</label>
</div>
<div className="mt-2">
<textarea
className="min-h-16 w-full resize-none rounded-lg border bg-transparent px-3 py-2 text-sm leading-relaxed placeholder:text-muted-foreground"
placeholder="导读(展示在标题下方的一句话摘要,建议 50 字内,不强制限字;留空则不展示)"
value={summary} onChange={(e) => setSummary(e.target.value)}
/>
</div>
{/* 分类 / 模式(第一行) */}
<div className="mt-4 flex flex-wrap items-center gap-3">
+77 -70
View File
@@ -9,17 +9,17 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { QRCodeSVG } from "@rc-component/qrcode";
import { Loader2, Lock, Smartphone, QrCode } from "lucide-react";
import { Loader2, Smartphone, QrCode } from "lucide-react";
import type { LoginResponse } from "@/types";
/** 二维码轮询登录(微信扫码 / 小程序扫码共用 */
/** 二维码轮询登录(小程序扫码):倒计时 + 失效蒙版 + 手动刷新(绝不自动刷新 */
function PollingQr({
start,
poll,
tip,
onResult,
}: {
start: () => Promise<{ scene: string; qr_url: string }>;
start: () => Promise<{ scene: string }>;
poll: (scene: string) => Promise<{ status: string; token?: string; profile?: LoginResponse }>;
tip: string;
onResult: (res: LoginResponse) => void;
@@ -28,7 +28,9 @@ function PollingQr({
const [qrImage, setQrImage] = React.useState("");
const [status, setStatus] = React.useState<"loading" | "pending" | "error">("loading");
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [left, setLeft] = React.useState(0);
const [expired, setExpired] = React.useState(false);
const [tick, setTick] = React.useState(0); // 手动刷新信号(唯一触发重拉二维码的途径)
const startRef = React.useRef(start);
const pollRef = React.useRef(poll);
const onResultRef = React.useRef(onResult);
@@ -36,53 +38,89 @@ function PollingQr({
pollRef.current = poll;
onResultRef.current = onResult;
const run = React.useCallback(async () => {
setStatus("loading");
setBusy(false);
try {
const r = await startRef.current();
const { scene, qr_url, qr_image } = r as { scene: string; qr_url?: string; qr_image?: string };
setQrUrl(qr_url || "");
setQrImage(qr_image || "");
setStatus("pending");
const timer = window.setInterval(async () => {
if (busy) return;
setBusy(true);
try {
const res = await pollRef.current(scene);
if (res.status === "done" && res.profile) {
window.clearInterval(timer);
setStatus("pending");
onResultRef.current(res.profile);
} else if (res.status === "expired") {
window.clearInterval(timer);
setStatus("error");
setError("二维码已过期,请刷新");
React.useEffect(() => {
let timer: number | undefined;
let countdown: number | undefined;
let handled = false;
let cancelled = false;
const clear = () => {
if (timer) window.clearInterval(timer);
if (countdown) window.clearInterval(countdown);
timer = undefined; countdown = undefined;
};
(async () => {
setStatus("loading");
setExpired(false);
try {
const r = await startRef.current();
if (cancelled) return;
const { scene, qr_url, qr_image, expires_in } = r as { scene: string; qr_url?: string; qr_image?: string; expires_in?: number };
setQrUrl(qr_url || "");
setQrImage(qr_image || "");
setStatus("pending");
// 截止时间驱动倒计时:纯更新 + 一次性失效处理
const deadline = Date.now() + (expires_in || 120) * 1000;
setLeft(Math.round((deadline - Date.now()) / 1000));
countdown = window.setInterval(() => {
const v = Math.max(0, Math.round((deadline - Date.now()) / 1000));
setLeft((prev) => (prev === v ? prev : v));
if (v <= 0 && !handled) {
handled = true;
clear(); // 停止轮询,等待手动刷新
setExpired(true);
}
} finally { setBusy(false); }
}, 2000);
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "获取二维码失败");
}
}, 500);
timer = window.setInterval(async () => {
try {
const res = await pollRef.current(scene);
if (cancelled || handled) return;
if (res.status === "done" && res.profile) {
handled = true;
clear();
onResultRef.current(res.profile);
} else if (res.status === "expired") {
handled = true;
clear();
setExpired(true);
}
} catch { /* 单次轮询失败忽略,下一轮继续 */ }
}, 2000);
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "获取二维码失败");
}
})();
return () => { cancelled = true; clear(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => { run(); }, [run]);
}, [tick]);
if (status === "loading") return <div className="py-10 text-center text-sm text-muted-foreground"></div>;
if (status === "error")
return (
<div className="py-10 text-center text-sm">
<p className="text-destructive">{error}</p>
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={run}></Button>
<Button type="button" variant="outline" size="sm" className="mt-3" onClick={() => setTick((t) => t + 1)}></Button>
</div>
);
return (
<div className="flex flex-col items-center gap-3 py-4">
<div className="rounded-lg bg-white p-2">
{qrImage ? <img src={qrImage} alt="小程序码" className="h-[180px] w-[180px]" /> : <QRCodeSVG value={qrUrl} size={180} />}
<div className="relative rounded-lg bg-white p-2">
<div className={expired ? "blur-[4px] opacity-45" : ""}>
{qrImage ? <img src={qrImage} alt="小程序码" className="h-[180px] w-[180px]" /> : <QRCodeSVG value={qrUrl} size={180} />}
</div>
{expired && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<span className="text-[13px] font-semibold text-[#5a6aa3]"></span>
<button type="button" className="text-[13px] text-primary underline-offset-2 hover:underline"
onClick={() => setTick((t) => t + 1)}></button>
</div>
)}
</div>
{!expired && (
<div className="text-xs text-muted-foreground">
{Math.floor(left / 60)}:{String(left % 60).padStart(2, "0")}
</div>
)}
<p className="text-sm text-muted-foreground">{tip}</p>
</div>
);
@@ -91,9 +129,7 @@ function PollingQr({
export default function LoginPage() {
const { isAuthed, login, loginWithResponse } = useAuth();
const navigate = useNavigate();
const [pmode, setPmode] = React.useState<"account" | "sms" | "miniprogram">("account");
const [username, setUsername] = React.useState("pine");
const [password, setPassword] = React.useState("123456");
const [pmode, setPmode] = React.useState<"sms" | "miniprogram">("sms");
const [phone, setPhone] = React.useState("");
const [smsCode, setSmsCode] = React.useState("");
const [smsCountdown, setSmsCountdown] = React.useState(0);
@@ -110,17 +146,6 @@ export default function LoginPage() {
navigate(res.role === "carrier" ? "/park" : "/", { replace: true });
}
async function onAccountSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null); setLoading(true);
try {
clearParkAuth();
await applyPlatformResult(await login(username, password));
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally { setLoading(false); }
}
async function onSmsSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null); setLoading(true);
@@ -159,28 +184,10 @@ export default function LoginPage() {
<CardContent>
<Tabs value={pmode} onValueChange={(v) => { setPmode(v as typeof pmode); setError(null); }}>
<TabsList className="w-full mt-2">
<TabsTrigger value="account" className="flex-1"><Lock className="mr-1 h-3 w-3" /></TabsTrigger>
<TabsTrigger value="sms" className="flex-1"><Smartphone className="mr-1 h-3 w-3" /></TabsTrigger>
<TabsTrigger value="miniprogram" className="flex-1"><QrCode className="mr-1 h-3 w-3" /></TabsTrigger>
</TabsList>
<TabsContent value="account">
<form onSubmit={onAccountSubmit} className="mt-3 space-y-4">
<div className="space-y-1">
<Label htmlFor="username"></Label>
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</div>
<div className="space-y-1">
<Label htmlFor="password"></Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="animate-spin" />}
</Button>
</form>
</TabsContent>
<TabsContent value="sms">
<form onSubmit={onSmsSubmit} className="mt-3 space-y-4">
<div className="space-y-1">
+20 -2
View File
@@ -27,7 +27,14 @@ const TYPES = [
{ value: "dynamic", label: "动态" },
];
const EMPTY = { type: "news", title: "", card_mode: "small", cover: "", summary: "", body: "" };
const EMPTY = { type: "news", title: "", card_mode: "small", cover: "", summary: "", body: "", source: "", published_at: "" };
/** datetime-local → ISO(UTC);空返回空 */
function localToIso(v: string): string {
if (!v) return "";
const d = new Date(v);
return isNaN(d.getTime()) ? "" : d.toISOString();
}
/** 园区资讯管理:仅本园区内容(carrier 自动定位本园区,列表/发布均已按本园区隔离)。 */
export function ContentTab() {
@@ -38,9 +45,13 @@ export function ContentTab() {
const publish = async () => {
if (!form.title.trim()) return;
if (form.published_at) {
const t = new Date(form.published_at).getTime();
if (t < Date.now() - 3 * 86400_000) { window.alert("发布时间不能早于 3 天前"); return; }
}
setBusy(true);
try {
await parkApi.publishContent(form);
await parkApi.publishContent({ ...form, published_at: localToIso(form.published_at) });
setOpen(false); setForm(EMPTY);
res.reload();
window.alert("已提交,待运营端审核通过后展示到 web/小程序端");
@@ -95,6 +106,13 @@ export function ContentTab() {
<div className="space-y-1.5"><Label></Label><Input value={form.title ?? ""} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} placeholder="资讯标题" /></div>
<div className="space-y-1.5"><Label> URL 3.35:1</Label><Input value={form.cover ?? ""} onChange={(e) => setForm((f) => ({ ...f, cover: e.target.value }))} placeholder="https://…" /></div>
<div className="space-y-1.5"><Label></Label><Input value={form.summary ?? ""} onChange={(e) => setForm((f) => ({ ...f, summary: e.target.value }))} placeholder="一句话摘要" /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label></Label><Input value={form.source ?? ""} onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))} placeholder="如:园区服务中心" /></div>
<div className="space-y-1.5"><Label></Label>
<input type="datetime-local" className="h-9 w-full rounded-md border border-input bg-transparent px-2 text-sm" value={form.published_at ?? ""} onChange={(e) => setForm((f) => ({ ...f, published_at: e.target.value }))} />
<p className="text-xs text-muted-foreground">= 3 </p>
</div>
</div>
<div className="space-y-1.5"><Label></Label>
<textarea className="min-h-40 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground" value={form.body ?? ""} onChange={(e) => setForm((f) => ({ ...f, body: e.target.value }))} placeholder="正文内容" />
</div>