@airalogy/airalogy-bd 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.3.1] - 2026-10-27
8
+
9
+ - 将「待确认」移到右上角头像旁,有草稿时显示数量,没有草稿时使用弱化样式,保持主导航聚焦于工作台和管理中心。
10
+ - 数量仅统计当前账号有权查看的有效待确认草稿,与草稿列表共用权限和有效期检查;处理草稿后立即刷新,页面可见时定期更新。
11
+ - 调整手机端顶部操作布局,并补充数量接口的账号隔离、权限撤销、过期及处理状态测试。
12
+
7
13
  ## [1.3.0] - 2026-10-27
8
14
 
9
15
  - 统一界面字体、字号与行高,改善正文、表格和说明文字的可读性,手机端通过布局适配而非缩小正文。
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import Link from "next/link";
4
+ import PendingChangesLink from "./PendingChangesLink";
4
5
  import { usePathname, useRouter } from "next/navigation";
5
6
  import { BrandIdentity, BrandLogo } from "./Branding";
6
7
  import { teamRequest, type TeamUser } from "./lib/team-client";
@@ -25,13 +26,12 @@ export default function AccountBar({ user, workspace }: { user: TeamUser | null;
25
26
  <Link className="app-brand" href="/" aria-label="返回工作台">{workspace?<><BrandLogo/><span><strong>{workspace.short_name}</strong><small>{workspace.workspace_title}</small></span></>:<BrandIdentity/>}</Link>
26
27
  <nav className="app-navigation" aria-label="全局导航">
27
28
  <Link href="/" aria-current={pathname==="/"?"page":undefined}>工作台</Link>
28
- {user&&<Link href="/remote" aria-current={pathname==="/remote"?"page":undefined}>待确认修改</Link>}
29
29
  {(!user||user.role==="admin")&&<Link href="/admin" aria-current={pathname==="/admin"?"page":undefined}>管理中心</Link>}
30
30
  </nav>
31
- {user?<details className="app-account" ref={menu} onKeyDown={event=>{if(event.key==="Escape"&&menu.current){menu.current.open=false;menu.current.querySelector("summary")?.focus();}}}>
31
+ {user?<div className="app-account-actions"><PendingChangesLink key={user.id}/><details className="app-account" ref={menu} onKeyDown={event=>{if(event.key==="Escape"&&menu.current){menu.current.open=false;menu.current.querySelector("summary")?.focus();}}}>
32
32
  <summary aria-label={`${user.name},账号菜单`} className={pathname==="/account"?"is-current":undefined}><span className="app-avatar" aria-hidden="true">{Array.from(user.name)[0]}</span><span className="app-account-name">{user.name}<small>{{admin:"管理员",editor:"编辑成员",viewer:"只读成员"}[user.role]}</small></span><span className="app-account-chevron" aria-hidden="true">⌄</span></summary>
33
33
  <div className="app-account-menu"><Link href="/account" onClick={()=>{if(menu.current)menu.current.open=false;}}>我的账号</Link><Link href="/account#ai-tools" onClick={()=>{if(menu.current)menu.current.open=false;}}>AI 工具连接</Link><button type="button" onClick={()=>void logout()}>退出登录</button></div>
34
- </details>:<span className="app-local-label">本机工作区</span>}
34
+ </details></div>:<span className="app-local-label">本机工作区</span>}
35
35
  </div>
36
36
  </header>
37
37
  {error&&<p className="admin-alert" role="alert">{error}</p>}
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { SettingsIcon } from "./SettingsUI";
6
+ import { REMOTE_DRAFTS_CHANGED } from "./lib/remote-events";
7
+
8
+ export default function PendingChangesLink() {
9
+ const pathname = usePathname();
10
+ const [count, setCount] = useState<number | null>(null);
11
+ const [unavailable, setUnavailable] = useState(false);
12
+ useEffect(() => {
13
+ let controller: AbortController | null = null;
14
+ let disposed = false;
15
+ async function refresh(force = false) {
16
+ if (document.visibilityState === "hidden" || (controller && !force)) return;
17
+ controller?.abort();
18
+ const current = new AbortController();
19
+ controller = current;
20
+ try {
21
+ const response = await fetch("/api/remote?action=pending", { cache: "no-store", signal: current.signal });
22
+ if (!response.ok) throw new Error("unavailable");
23
+ const body = await response.json();
24
+ if (!body.ok || !Number.isSafeInteger(body.result?.count) || body.result.count < 0) throw new Error("invalid count");
25
+ if (!disposed && !current.signal.aborted) { setCount(body.result.count); setUnavailable(false); }
26
+ } catch {
27
+ if (!disposed && !current.signal.aborted) { setCount(null); setUnavailable(true); }
28
+ } finally {
29
+ if (controller === current) controller = null;
30
+ }
31
+ }
32
+ const onVisible = () => void refresh();
33
+ const onChanged = () => void refresh(true);
34
+ void refresh();
35
+ const timer = window.setInterval(onVisible, 60_000);
36
+ window.addEventListener("focus", onVisible);
37
+ document.addEventListener("visibilitychange", onVisible);
38
+ window.addEventListener(REMOTE_DRAFTS_CHANGED, onChanged);
39
+ return () => {
40
+ disposed = true;
41
+ controller?.abort();
42
+ window.clearInterval(timer);
43
+ window.removeEventListener("focus", onVisible);
44
+ document.removeEventListener("visibilitychange", onVisible);
45
+ window.removeEventListener(REMOTE_DRAFTS_CHANGED, onChanged);
46
+ };
47
+ }, [pathname]);
48
+ const hasPending = count !== null && count > 0;
49
+ const label = hasPending ? `待确认修改,${count} 条草稿` : count === 0 ? "待确认修改,暂无草稿" : "待确认修改";
50
+ return <Link href="/remote" className={`app-pending${hasPending ? " has-pending" : " is-muted"}`}
51
+ aria-current={pathname === "/remote" ? "page" : undefined} aria-label={label}
52
+ title={unavailable ? "数量暂时无法获取,点击查看待确认修改" : label}>
53
+ <SettingsIcon name="review"/><span>待确认</span>
54
+ {hasPending && <span className="app-pending-count" aria-hidden="true">{count > 99 ? "99+" : count}</span>}
55
+ </Link>;
56
+ }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { useCallback, useEffect, useState } from "react";
3
3
  import Link from "next/link";
4
+ import { notifyRemoteDraftsChanged } from "./lib/remote-events";
4
5
  import {SettingsPanelHeader,SettingsStatus} from "./SettingsUI";
5
6
  import type { TeamUser } from "./lib/team-client";
6
7
  import { stageLabels, followUpStatusLabels } from "./lib/labels";
@@ -53,6 +54,7 @@ export default function RemoteAccess({ user, mode, endpoint = "" }: { user: Team
53
54
  setBusy(true); setError(""); setNotice("");
54
55
  try {
55
56
  const result = await request<{ token?: string }>(action, input);
57
+ notifyRemoteDraftsChanged();
56
58
  if (result.token) setSecret(result.token);
57
59
  setNotice(message); await reload();
58
60
  } catch (err) { setError((err as Error).message); }
@@ -1,6 +1,6 @@
1
1
  import type {ReactNode} from "react";
2
2
 
3
- export type SettingsIconName = "members" | "ai" | "usage" | "remote" | "backup" | "plus" | "search" | "lock";
3
+ export type SettingsIconName = "members" | "ai" | "usage" | "remote" | "backup" | "plus" | "search" | "lock" | "review";
4
4
  const paths:Record<SettingsIconName,ReactNode> = {
5
5
  members:<><circle cx="9" cy="8" r="3"/><path d="M3 20v-2a6 6 0 0 1 12 0v2M16 5a3 3 0 0 1 0 6m2 3a5 5 0 0 1 3 4v2"/></>,
6
6
  ai:<><path d="m12 3 2.7 6.3L21 12l-6.3 2.7L12 21l-2.7-6.3L3 12l6.3-2.7L12 3Z"/></>,
@@ -9,6 +9,7 @@ const paths:Record<SettingsIconName,ReactNode> = {
9
9
  backup:<><path d="M12 3v12m-4-4 4 4 4-4M4 15v5h16v-5"/></>,
10
10
  plus:<path d="M12 5v14M5 12h14"/>,
11
11
  search:<><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></>,
12
+ review:<><path d="M9 4H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4"/><rect x="9" y="2" width="6" height="4" rx="1"/><path d="m8 13 3 3 5-6"/></>,
12
13
  lock:<><rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4m-4 5v2"/></>,
13
14
  };
14
15
  export function SettingsIcon({name}:{name:SettingsIconName}) {
@@ -8,7 +8,7 @@ export async function GET(request: Request) {
8
8
  const access = await authorizedRequest(request);
9
9
  if (access instanceof Response) return access;
10
10
  const operation = new URL(request.url).searchParams.get("action") || "status";
11
- if (!["status", "settings", "drafts"].includes(operation)) return denied();
11
+ if (!["status", "settings", "drafts", "pending"].includes(operation)) return denied();
12
12
  return serviceResponse(await invokeWorkspace({ action: "remote_browser", operation }, access.session));
13
13
  }
14
14
  export async function POST(request: Request) {
@@ -0,0 +1,5 @@
1
+ export const REMOTE_DRAFTS_CHANGED = "bd:remote-drafts-changed";
2
+
3
+ export function notifyRemoteDraftsChanged() {
4
+ window.dispatchEvent(new Event(REMOTE_DRAFTS_CHANGED));
5
+ }
@@ -321,3 +321,27 @@
321
321
  .auth-page { padding:24px 16px; }
322
322
  .auth-card { padding:26px 22px; }
323
323
  }
324
+
325
+ /* Pending reviews are account actions, separate from the main navigation. */
326
+ .app-account-actions { display:flex; align-items:center; gap:14px; margin-left:auto; flex-shrink:0; }
327
+ .app-account-actions .app-account { margin-left:0; }
328
+ .app-pending { display:inline-flex; align-items:center; gap:6px; min-height:38px; padding:8px 10px; border:1px solid transparent; border-radius:8px; color:var(--muted); font-size:var(--text-small); line-height:var(--leading-compact); text-decoration:none; white-space:nowrap; }
329
+ .app-pending .settings-icon { width:17px; height:17px; }
330
+ .app-pending:hover,.app-pending[aria-current=page] { background:var(--canvas); color:var(--navy); }
331
+ .app-pending.has-pending { color:var(--blue); background:var(--blue-soft); }
332
+ .app-pending.has-pending:hover,.app-pending.has-pending[aria-current=page] { border-color:#cbddeb; }
333
+ .app-pending-count { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 5px; border-radius:10px; background:var(--blue); color:white; font-size:var(--text-caption); font-weight:600; font-variant-numeric:tabular-nums; }
334
+ @media(max-width:760px) {
335
+ .app-account-actions { order:1; gap:8px; }
336
+ .app-account-actions .app-account { order:0; }
337
+ }
338
+ @media(max-width:520px) {
339
+ .app-header-inner { padding-left:14px; padding-right:14px; gap:10px; }
340
+ .app-account-actions { gap:4px; }
341
+ .app-account summary { padding:5px; gap:4px; }
342
+ .app-account-name,.app-account-chevron { display:none; }
343
+ .app-pending { padding:7px; gap:5px; }
344
+ .app-pending .settings-icon { display:none; }
345
+ .app-brand > span:not(.site-brand-identity) { min-width:0; }
346
+ .app-brand strong,.app-brand small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
347
+ }
@@ -18,6 +18,6 @@ Airalogy BD 使用系统无衬线字体:优先采用操作系统界面字体
18
18
 
19
19
  同类元素在不同页面共用字号,不通过不断增加字号区分信息层级;辅助信息优先用字重、颜色和间距区分。手机端通过换行、单列布局和表格横向滚动适配,不缩小正文。主要表单的手机端输入字号为 16px,避免聚焦输入时触发浏览器自动缩放。
20
20
 
21
- 公司标识位于应用最上方,工作区导航和账号操作在同一栏;页面标题、页面操作和局部导航位于下方。管理中心负责全局配置和成员权限;个人连接由成员在“我的账号”管理。
21
+ 公司标识位于应用最上方,工作区导航和账号操作在同一栏;「待确认」作为头像旁的操作入口,有草稿时显示数量,没有时弱化显示;页面标题、页面操作和局部导航位于下方。管理中心负责全局配置和成员权限;个人连接由成员在“我的账号”管理。
22
22
 
23
23
  导出报告有独立的纸张与排版要求,使用报告组件的专用字号,不随界面字号批量调整。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airalogy/airalogy-bd",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Business development workspace for local use and team collaboration.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -108,6 +108,10 @@ class RemoteAccess:
108
108
  def browser(self, request, actor):
109
109
  op = request.get("operation")
110
110
  if op == "status": return self.status(actor)
111
+ if op == "pending":
112
+ if not self.settings()["enabled"] or not self.accounts.remote_allowed(actor["id"]) or actor["role"] == "viewer":
113
+ return {"count": 0}
114
+ return {"count": sum(1 for _ in self.pending_drafts(actor))}
111
115
  if op in {"settings", "configure"}:
112
116
  self.accounts.require_admin(actor)
113
117
  if op == "configure":
@@ -152,13 +156,8 @@ class RemoteAccess:
152
156
  return {"revoked": True}
153
157
  if op == "drafts":
154
158
  self.grant(actor)
155
- policy, drafts = Policy(self.workspace, actor), []
156
- for row in self.db.execute("SELECT * FROM remote_drafts WHERE owner=? AND state='pending' ORDER BY created DESC", (actor["id"],)):
157
- try:
158
- self.check_draft(row, actor, policy)
159
- drafts.append({"id": row["id"], "created": row["created"], "expires": row["expires"], **json.loads(row["value"])["review"]})
160
- except AuthError: continue
161
- return {"drafts": drafts}
159
+ return {"drafts": [{"id": row["id"], "created": row["created"], "expires": row["expires"],
160
+ **json.loads(row["value"])["review"]} for row in self.pending_drafts(actor)]}
162
161
  if op in {"confirm", "reject"}:
163
162
  self.grant(actor)
164
163
  row = self.db.execute("SELECT * FROM remote_drafts WHERE id=?", (text(request.get("draftId")),)).fetchone()
@@ -189,6 +188,16 @@ class RemoteAccess:
189
188
  return result
190
189
  raise AuthError("未知的远程管理操作。", "invalid")
191
190
 
191
+ def pending_drafts(self, actor):
192
+ # The badge and review page use the same ownership, expiry and permission checks.
193
+ policy = Policy(self.workspace, actor)
194
+ for row in self.db.execute("SELECT * FROM remote_drafts WHERE owner=? AND state='pending' ORDER BY created DESC", (actor["id"],)):
195
+ try:
196
+ self.check_draft(row, actor, policy)
197
+ except AuthError:
198
+ continue
199
+ yield row
200
+
192
201
  def check_draft(self, row, actor, policy):
193
202
  if not row or row["owner"] != actor["id"] or row["binding"] != actor["revision"] or row["expires"] <= int(time.time()) or row["state"] != "pending":
194
203
  raise AuthError("草稿已处理或失效;请检查最新记录,必要时重新提交。", "conflict")