Compare commits

..

2 Commits

Author SHA1 Message Date
Pine 219136797f feat(console): 品牌统一云超服OPC + 扫码登录倒计时/失效蒙版
- branding.ts: name=云超服OPC + fullName=云南省超级个体服务平台;登录页标题/logo alt 同步
- 扫码登录组件:真实倒计时(截止时间驱动)+失效模糊蒙版+手动刷新(失效即停轮询,绝不自动刷新)
2026-08-28 17:28:20 +08:00
Pine e0017f3a9a brand: 顶栏/登录页/favicon/官方logo 统一使用 logo.png 2026-08-27 19:53:45 +08:00
6 changed files with 130 additions and 80 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/pineagents-icon.svg" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

+2 -1
View File
@@ -3,7 +3,8 @@
// 所有基于上游 QwenPaw 的改动登记在仓库根目录 NOTICE.md。
export const BRAND = {
name: "PineAgents", // 产品名(上游:QwenPaw
name: "云超服OPC", // 产品名(对外统一品牌;上游:QwenPaw→PineAgents
fullName: "云南省超级个体服务平台", // 品牌全称(正式场合使用)
cli: "pineagents", // CLI 命令(上游:qwenpaw/copaw
packageName: "pineagents", // 后端包名(已彻底改名)
envPrefix: "PINEAGENTS_", // 环境变量主前缀
+1 -1
View File
@@ -364,7 +364,7 @@ export default function Header() {
*/}
<Slot name="header.logo" kind="replace">
<img
src={isDark ? "/logo-dark.svg" : "/logo-light.svg"}
src="/logo.png"
alt="PineAgents"
className={styles.logoImg}
/>
+1 -1
View File
@@ -2937,7 +2937,7 @@
}
},
"login": {
"title": "登录 PineAgents",
"title": "登录 · 云超服OPC",
"registerTitle": "创建账户",
"firstUserHint": "创建管理员账户以开始使用",
"usernamePlaceholder": "用户名",
+125 -76
View File
@@ -58,6 +58,7 @@ function PollingQr({
const [state, setState] = useState<"loading" | "pending" | "done" | "error">("loading");
const [error, setError] = useState("");
const [countdown, setCountdown] = useState(0);
const [expired, setExpired] = useState(false);
const sceneRef = useRef("");
const startRef = useRef(start);
const pollRef = useRef(poll);
@@ -66,36 +67,53 @@ function PollingQr({
pollRef.current = poll;
onTokenRef.current = onToken;
const timersRef = useRef<{ timer?: number; countdown?: number }>({});
const clearTimers = useCallback(() => {
if (timersRef.current.timer) window.clearInterval(timersRef.current.timer);
if (timersRef.current.countdown) window.clearInterval(timersRef.current.countdown);
timersRef.current = {};
}, []);
const run = useCallback(async () => {
clearTimers(); // 先清上一轮,防 interval 叠加
setExpired(false);
setState("loading");
setCountdown(Math.round(pollTimeout / pollInterval));
try {
const r = await startRef.current();
sceneRef.current = r.scene;
setQrUrl(r.qr_url || "");
setQrImage(r.qr_image || "");
setState("pending");
const timer = window.setInterval(async () => {
// 二维码倒计时:绝对截止时间驱动(expires_in 后端下发,默认 120s);到期自动刷新
const expiresIn = (r as { expires_in?: number }).expires_in || 120;
const deadline = Date.now() + expiresIn * 1000;
setCountdown(expiresIn);
timersRef.current.countdown = window.setInterval(() => {
const v = Math.max(0, Math.round((deadline - Date.now()) / 1000));
setCountdown(v);
if (v <= 0) { clearTimers(); setExpired(true); } // 到期 → 停止轮询,等待手动刷新
}, 500);
timersRef.current.timer = window.setInterval(async () => {
const res = await pollRef.current(sceneRef.current);
if (res.status === "done" && res.token && res.profile) {
window.clearInterval(timer);
clearTimers();
setState("done");
onTokenRef.current(res.token, res.profile);
} else if (res.status === "expired") {
window.clearInterval(timer);
setState("error");
setError("二维码已过期,请刷新重试");
clearTimers();
setExpired(true); // 过期 → 停止轮询,等待手动刷新
}
}, pollInterval);
} catch (err) {
setState("error");
setError(err instanceof Error ? err.message : "获取二维码失败");
}
}, [pollInterval, pollTimeout]);
}, [clearTimers, pollInterval]);
useEffect(() => {
run();
}, [run]);
return clearTimers; // 卸载兜底
}, [run, clearTimers]);
if (state === "loading") {
return <div style={{ textAlign: "center", padding: 24 }}></div>;
@@ -112,16 +130,29 @@ function PollingQr({
<div style={{ textAlign: "center", padding: 8 }}>
<div
style={{
position: "relative",
display: "inline-block",
padding: 12,
background: "#fff",
borderRadius: 8,
}}
>
{qrImage ? <img src={qrImage} alt="小程序码" style={{ width: 200, height: 200 }} /> : <QRCode value={qrUrl} size={200} />}
<div style={expired ? { filter: "blur(4px)", opacity: 0.45 } : undefined}>
{qrImage ? <img src={qrImage} alt="小程序码" style={{ width: 200, height: 200 }} /> : <QRCode value={qrUrl} size={200} />}
</div>
{expired && (
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8 }}>
<span style={{ fontSize: 13, color: "#5a6aa3", fontWeight: 600 }}></span>
<Button type="link" style={{ padding: 0 }} onClick={run}></Button>
</div>
)}
</div>
<p style={{ marginTop: 12, color: "#888", fontSize: 13 }}>{tip}</p>
{countdown > 0 && <Text type="secondary"> {Math.round(countdown * pollInterval / 1000)}s </Text>}
{!expired && countdown > 0 && (
<p style={{ marginTop: 6, color: "#999", fontSize: 12 }}>
{`二维码 ${Math.floor(countdown / 60)}:${String(countdown % 60).padStart(2, "0")} 后失效`}
</p>
)}
</div>
);
}
@@ -136,8 +167,8 @@ export default function LoginPage() {
const [hasUsers, setHasUsers] = useState(true);
const [identityCandidates, setIdentityCandidates] = useState<IdentityInfo[] | null>(null);
const { message } = useAppMessage();
const [activeTab, setActiveTab] = useState("account");
const [enabledModes, setEnabledModes] = useState<string[]>(["password", "phone"]);
const [activeTab, setActiveTab] = useState("phone");
const [enabledModes, setEnabledModes] = useState<string[]>(["phone", "miniprogram"]);
const [phone, setPhone] = useState("");
const [smsCode, setSmsCode] = useState("");
const [smsCountdown, setSmsCountdown] = useState(0);
@@ -152,9 +183,10 @@ export default function LoginPage() {
return;
}
setHasUsers(res.has_users);
setEnabledModes(res.login_modes || ["password", "phone"]);
setEnabledModes(res.login_modes || ["phone", "miniprogram"]);
if (!res.has_users) {
setIsRegister(true);
setActiveTab("account"); // 首次部署仅账号注册可引导建号
}
})
.catch(() => {});
@@ -286,8 +318,8 @@ export default function LoginPage() {
>
<div style={{ textAlign: "center", marginBottom: 24 }}>
<img
src={isDark ? "/logo-dark.svg" : "/logo-light.svg"}
alt="PineAgents"
src="/logo.png"
alt="云南省超级个体服务平台"
style={{ height: 48, marginBottom: 12 }}
/>
<h2 style={{ margin: 0, fontWeight: 600, fontSize: 20 }}>
@@ -305,78 +337,95 @@ export default function LoginPage() {
onChange={setActiveTab}
centered
items={[
{
key: "account",
label: "账号登录",
children: (
<Form layout="vertical" onFinish={onFinish} autoComplete="off" size="large">
<Form.Item
name="username"
rules={[{ required: true, message: t("login.usernameRequired") }]}
>
<Input
prefix={<UserOutlined style={{ color: isDarkText }} />}
placeholder={t("login.usernamePlaceholder")}
autoFocus
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t("login.passwordRequired") }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: isDarkText }} />}
placeholder={t("login.passwordPlaceholder")}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 44, borderRadius: 8, fontWeight: 500 }}>
{t("login.submit")}
</Button>
</Form.Item>
<div style={{ textAlign: "center", marginTop: 8 }}>
<Button type="link" size="small" onClick={() => setIsRegister(!isRegister)}>
{isRegister ? "返回登录" : t("login.register")}
</Button>
</div>
{isRegister && (
<div style={{ textAlign: "center", color: isDarkText, fontSize: 12 }}>
使
</div>
)}
</Form>
),
},
/* 账号登录仅首次建号引导保留;日常入口关闭(短信 / 扫码登录) */
...(!hasUsers
? [{
key: "account",
label: "初始化账号",
children: (
<Form layout="vertical" onFinish={onFinish} autoComplete="off" size="large">
<Form.Item
name="username"
rules={[{ required: true, message: t("login.usernameRequired") }]}
>
<Input
prefix={<UserOutlined style={{ color: isDarkText }} />}
placeholder={t("login.usernamePlaceholder")}
autoFocus
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: t("login.passwordRequired") }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: isDarkText }} />}
placeholder={t("login.passwordPlaceholder")}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 44, borderRadius: 8, fontWeight: 500 }}>
{t("login.register")}
</Button>
</Form.Item>
<div style={{ textAlign: "center", marginTop: 8, color: isDarkText, fontSize: 12 }}>
</div>
</Form>
),
}]
: []),
...(enabledModes.includes("phone")
? [{
key: "phone",
label: "短信验证码",
label: "验证码登录",
children: (
<Form layout="vertical" size="large">
<Form.Item label="手机号">
<Input
prefix={<MobileOutlined style={{ color: isDarkText }} />}
placeholder="请输入 11 位手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
</Form.Item>
<Form.Item label={smsStub ? `验证码(演示:${smsStub}` : "验证码"}>
<Input
size="large"
prefix={<MobileOutlined style={{ color: isDarkText }} />}
placeholder="手机号"
maxLength={11}
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ""))}
style={{ borderRadius: 8 }}
/>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<Input
size="large"
prefix={<MessageOutlined style={{ color: isDarkText }} />}
placeholder="请输入验证码"
placeholder="验证码"
maxLength={6}
value={smsCode}
onChange={(e) => setSmsCode(e.target.value)}
suffix={
<Button type="link" disabled={smsCountdown > 0} onClick={sendSms}>
{smsCountdown > 0 ? `${smsCountdown}s` : "发送验证码"}
</Button>
}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, ""))}
onPressEnter={onSmsLogin}
style={{ borderRadius: 8 }}
/>
</Form.Item>
<Button type="primary" block loading={loading} onClick={onSmsLogin} style={{ height: 44, borderRadius: 8, fontWeight: 500 }}>
<Button
size="large"
disabled={smsCountdown > 0 || !/^1\d{10}$/.test(phone)}
onClick={sendSms}
style={{ width: 116, flexShrink: 0, borderRadius: 8 }}
>
{smsCountdown > 0 ? `${smsCountdown}s 后重发` : "获取验证码"}
</Button>
</div>
<Button
type="primary"
block
size="large"
loading={loading}
disabled={!/^1\d{10}$/.test(phone) || !smsCode}
onClick={onSmsLogin}
style={{ height: 44, borderRadius: 8, fontWeight: 500, marginTop: 20 }}
>
</Button>
{smsStub && (
<div style={{ textAlign: "center", marginTop: 12, color: isDarkText, fontSize: 12 }}>
<Text copyable={{ text: smsStub }} style={{ fontSize: 12 }}>{smsStub}</Text>
</div>
)}
</Form>
),
}]