Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 219136797f | |||
| e0017f3a9a |
+1
-1
@@ -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 |
@@ -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_", // 环境变量主前缀
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -2937,7 +2937,7 @@
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"title": "登录 PineAgents",
|
||||
"title": "登录 · 云超服OPC",
|
||||
"registerTitle": "创建账户",
|
||||
"firstUserHint": "创建管理员账户以开始使用",
|
||||
"usernamePlaceholder": "用户名",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}]
|
||||
|
||||
Reference in New Issue
Block a user