feat(login): 登录改造(去账密/短信+扫码) + 资讯发布时间/来源/导读表单
- 登录页移除账号密码 tab,仅短信+小程序扫码;扫码组件重写:倒计时+失效模糊蒙版+手动刷新(失效即停轮询,绝不自动刷新) - 文章编辑器:发布时间(datetime-local,定时发布)+来源+导读(独立编辑不限字数)+作者留空显示官方 - 园区端发布:来源+发布时间(禁早于3天前校验),无发布人输入
This commit is contained in:
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user