feat: 增加演示身份下拉并保留令牌权限边界
This commit is contained in:
@@ -6,16 +6,29 @@ from urllib.parse import urlsplit
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
ROLES = ("admin", "reviewer", "manager")
|
||||
DEFAULT_DEMO_PROJECTS = "DEMO-A,DEMO-B,DEMO-C,LAB-001,XB-2026-001"
|
||||
|
||||
|
||||
def authorize(request: Request):
|
||||
configured = os.getenv("XINGBANG_AUTH_USERS", "")
|
||||
if not configured:
|
||||
demo_role = request.headers.get("x-xingbang-demo-role", "").strip()
|
||||
if demo_role:
|
||||
forwarded_host = request.headers.get("x-xingbang-client-host", request.headers.get("host", ""))
|
||||
hostname = urlsplit("//"+forwarded_host).hostname
|
||||
local_preview = not configured and os.getenv("XINGBANG_REQUIRE_AUTH") != "1" and request.client and request.client.host in ("127.0.0.1", "::1", "testclient") and hostname in ("localhost", "127.0.0.1", "::1", "testserver")
|
||||
if not (os.getenv("XINGBANG_DEMO_ROLE_LOGIN") == "1" or local_preview) or demo_role not in ROLES:
|
||||
raise HTTPException(401, "演示身份未启用或身份无效")
|
||||
projects = [item.strip() for item in os.getenv("XINGBANG_DEMO_PROJECTS", DEFAULT_DEMO_PROJECTS).split(",") if item.strip()]
|
||||
user = {"id": f"demo-{demo_role}", "role": demo_role, "projects": projects, "preview": False, "demo": True}
|
||||
elif not configured:
|
||||
forwarded_host = request.headers.get("x-xingbang-client-host", request.headers.get("host", ""))
|
||||
hostname = urlsplit("//"+forwarded_host).hostname
|
||||
if os.getenv("XINGBANG_REQUIRE_AUTH") == "1" or not request.client or request.client.host not in ("127.0.0.1", "::1", "testclient"):
|
||||
raise HTTPException(401, "请配置服务器身份认证后访问")
|
||||
if hostname not in ("localhost", "127.0.0.1", "::1", "testserver"):
|
||||
raise HTTPException(401, "本机预览仅接受回环地址;远程访问必须配置身份认证")
|
||||
user = {"id": "local-preview", "role": "admin", "projects": ["*"], "preview": True}
|
||||
user = {"id": "local-preview", "role": "admin", "projects": ["*"], "preview": True, "demo": True}
|
||||
else:
|
||||
try:
|
||||
accounts = json.loads(configured)
|
||||
@@ -23,10 +36,11 @@ def authorize(request: Request):
|
||||
raise HTTPException(503, "身份配置无效")
|
||||
supplied = request.headers.get("authorization", "").removeprefix("Bearer ")
|
||||
user = next((dict(u) for u in accounts if isinstance(u.get("token"), str) and hmac.compare_digest(u["token"], supplied)), None)
|
||||
if not user or user.get("role") not in ("admin", "reviewer", "manager"):
|
||||
if not user or user.get("role") not in ROLES:
|
||||
raise HTTPException(401, "访问令牌无效")
|
||||
user.pop("token", None)
|
||||
user["preview"] = False
|
||||
user["demo"] = False
|
||||
request.state.identity = user
|
||||
path = request.url.path.split("/risk-agent/", 1)[-1]
|
||||
if user["role"] == "manager":
|
||||
|
||||
@@ -77,6 +77,20 @@ class WorkflowTests(unittest.TestCase):
|
||||
self.assertEqual(self.call("/plans/plan-default/run","POST",{"project_id":"P2"},"manager-token").status_code,403)
|
||||
result=self.call("/plans/plan-default/run","POST",{"project_id":"XB-2026-001"},"manager-token").json()
|
||||
self.assertNotIn("configuration",result)
|
||||
def test_demo_role_dropdown_is_gated_and_preserves_role_permissions(self):
|
||||
header=lambda role:{"X-Xingbang-Demo-Role":role}
|
||||
self.assertEqual(self.client.get(self.base+"/access/context",headers=header("manager")).status_code,401)
|
||||
with patch.dict(os.environ,{"XINGBANG_DEMO_ROLE_LOGIN":"1"}):
|
||||
for role in ("admin","reviewer","manager"):
|
||||
response=self.client.get(self.base+"/access/context",headers=header(role))
|
||||
self.assertEqual(response.status_code,200,response.text)
|
||||
identity=response.json()
|
||||
self.assertEqual(identity["role"],role)
|
||||
self.assertTrue(identity["demo"])
|
||||
self.assertNotIn("*",identity["projects"])
|
||||
self.assertEqual(self.client.get(self.base+"/access/context",headers=header("owner")).status_code,401)
|
||||
self.assertEqual(self.client.post(self.base+"/cards",headers=header("manager"),json={"name":"x"}).status_code,403)
|
||||
self.assertEqual(self.client.post(self.base+"/cards",headers=header("reviewer"),json={"name":"x"}).status_code,403)
|
||||
def test_review_publishes_exact_version(self):
|
||||
item=self.call("/skills","POST",{"name":"新增检查","data":{"engine":"quality","inputs":["*"]}}).json()
|
||||
item["status"]="published"
|
||||
|
||||
@@ -9,6 +9,9 @@ DEEPSEEK_MODEL=deepseek-chat
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
# For the gateway tested in the chat repair use disabled; otherwise verify support.
|
||||
CHAT_THINKING_MODE=
|
||||
# Temporary business-workbench role dropdown. Keep disabled for production data.
|
||||
XINGBANG_DEMO_ROLE_LOGIN=0
|
||||
XINGBANG_DEMO_PROJECTS=DEMO-A,DEMO-B,DEMO-C,LAB-001,XB-2026-001
|
||||
|
||||
ERP_DB_HOST=
|
||||
ERP_DB_PORT=5432
|
||||
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
|
||||
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
|
||||
CHAT_THINKING_MODE: ${CHAT_THINKING_MODE:-}
|
||||
XINGBANG_DEMO_ROLE_LOGIN: ${XINGBANG_DEMO_ROLE_LOGIN:-0}
|
||||
XINGBANG_DEMO_PROJECTS: ${XINGBANG_DEMO_PROJECTS:-DEMO-A,DEMO-B,DEMO-C,LAB-001,XB-2026-001}
|
||||
ERP_DB_HOST: ${ERP_DB_HOST:-}
|
||||
ERP_DB_PORT: ${ERP_DB_PORT:-5432}
|
||||
ERP_DB_NAME: ${ERP_DB_NAME:-}
|
||||
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}
|
||||
DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}
|
||||
CHAT_THINKING_MODE: ${CHAT_THINKING_MODE:-}
|
||||
XINGBANG_DEMO_ROLE_LOGIN: ${XINGBANG_DEMO_ROLE_LOGIN:-0}
|
||||
XINGBANG_DEMO_PROJECTS: ${XINGBANG_DEMO_PROJECTS:-DEMO-A,DEMO-B,DEMO-C,LAB-001,XB-2026-001}
|
||||
ERP_DB_HOST: ${ERP_DB_HOST:-}
|
||||
ERP_DB_PORT: ${ERP_DB_PORT:-5432}
|
||||
ERP_DB_NAME: ${ERP_DB_NAME:-}
|
||||
|
||||
@@ -62,6 +62,7 @@ function responder(chunks) {return async()=>new Response(new ReadableStream({sta
|
||||
assert.equal((await route.GET(request,{params:{path:['unknown']}})).status,404);
|
||||
process.env.NODE_ENV='production';
|
||||
assert.equal((await route.GET(request,{params:{path:['sessions']}})).status,401);
|
||||
assert.equal((await route.GET({...request,headers:new Headers({Host:'cloud.example:3200','X-Xingbang-Demo-Role':'admin'})},{params:{path:['sessions']}})).status,401);
|
||||
for (const identity of [{id:'a',role:'admin'},{id:'r',role:'reviewer'},{id:'m',role:'manager'},{id:'p',role:'admin',preview:true},{}]) {
|
||||
let forwarded=false;
|
||||
global.fetch=async(url,opts)=>{
|
||||
@@ -78,6 +79,12 @@ function responder(chunks) {return async()=>new Response(new ReadableStream({sta
|
||||
const secured=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}});
|
||||
assert.equal((await secured.GET({...request,headers:new Headers({Authorization:'Bearer invalid'})},{params:{path:['sessions']}})).status,status===500?503:status);
|
||||
}
|
||||
let riskHeaders;
|
||||
global.fetch=async(_url,opts)=>{riskHeaders=opts.headers;return Response.json({role:'reviewer'});};
|
||||
const riskRoute=load('src/app/api/risk-agent/[...path]/route.ts',{'next/server':{NextResponse}});
|
||||
const riskResponse=await riskRoute.GET({...request,headers:new Headers({Host:'cloud.example:3200','X-Xingbang-Demo-Role':'reviewer'})},{params:{path:['access','context']}});
|
||||
assert.equal(riskResponse.status,200);
|
||||
assert.equal(riskHeaders['X-Xingbang-Demo-Role'],'reviewer');
|
||||
let forwardedHeaders;
|
||||
global.fetch=async(_url,opts)=>{forwardedHeaders=opts.headers;return Response.json({sessions:[]});};
|
||||
const {coreFetch}=load('src/lib/core-fetch.ts',{}, {window:{sessionStorage:{getItem:key=>key==='xingbang-access-token'?'existing-token':null}}});
|
||||
|
||||
@@ -4,7 +4,7 @@ async function proxy(request: NextRequest, {params}: {params:{path:string[]}}) {
|
||||
const base=process.env.RISK_AGENT_BACKEND_URL || "http://127.0.0.1:8002";
|
||||
try {
|
||||
const response=await fetch(`${base}/api/xingbang/risk-agent/${params.path.map(encodeURIComponent).join("/")}${request.nextUrl.search}`,{
|
||||
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Client-Host":request.headers.get("host")||"", "Idempotency-Key":request.headers.get("idempotency-key")||""},
|
||||
method:request.method,headers:{"Content-Type":request.headers.get("content-type")||"application/json", "Authorization":request.headers.get("authorization")||"", "X-Xingbang-Demo-Role":request.headers.get("x-xingbang-demo-role")||"", "X-Xingbang-Client-Host":request.headers.get("host")||"", "Idempotency-Key":request.headers.get("idempotency-key")||""},
|
||||
body:["GET","HEAD"].includes(request.method)?undefined:await request.arrayBuffer(),cache:"no-store",
|
||||
});
|
||||
const headers=new Headers({"Content-Type":response.headers.get("content-type")||"application/json"});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -24,7 +24,18 @@ export function RuleFields({item,field,json,setJson}:{item:any;field:Fields["fie
|
||||
return <><Field label="人为自然语言规则"><textarea placeholder="例如:结合利润与经营现金流的变化识别疑点,逐项列明证据不足之处,只给核查建议。" value={item.natural_language||""} onChange={e=>field("natural_language",e.target.value)}/></Field><p className="ra-note">所选规则随平级数据卡送入本地模型;模型不可修改规则或作出审批决定。模型未配置时明确显示该项未完成。</p><h3>确定性检查条件</h3>{!invalid&&conditions.map((c,i)=><div className="ra-rule-row" key={i}><Field label="检查名称"><input value={c.title||""} onChange={e=>change(i,"title",e.target.value)}/></Field><div className="ra-two"><Field label="标准字段"><input value={c.field||""} onChange={e=>change(i,"field",e.target.value)}/></Field><Field label="判断条件"><select value={c.operator||"gt"} onChange={e=>change(i,"operator",e.target.value)}><option value="gt">大于</option><option value="lt">小于</option><option value="eq">等于</option></select></Field></div><Field label="阈值"><input type="number" step="any" value={c.threshold??0} onChange={e=>change(i,"threshold",Number(e.target.value))}/></Field><Field label="核查建议"><input value={c.suggestion||""} onChange={e=>change(i,"suggestion",e.target.value)}/></Field><button className="ra-button" title="删除条件" onClick={()=>setJson(JSON.stringify(conditions.filter((_,j)=>j!==i),null,2))}><Trash2 size={15}/></button></div>)}<button className="ra-button" disabled={invalid} onClick={()=>setJson(JSON.stringify([...conditions,{title:"新检查",field:"metrics.debt_ratio",operator:"gt",threshold:0.7,suggestion:"请核实"}],null,2))}><Plus size={15}/>添加条件</button><details className="ra-note"><summary>高级 JSON 配置</summary><Field label="条件 JSON"><textarea className="ra-code" value={json} onChange={e=>setJson(e.target.value)}/></Field></details></>
|
||||
}
|
||||
|
||||
export function AccessDialog({close,connected}:{close:()=>void;connected:()=>void}){const [token,setToken]=useState("");return <div className="ra-overlay"><section className="ra-confirm" role="dialog" aria-modal="true"><button style={{float:"right"}} title="关闭身份窗口" onClick={close}><X size={18}/></button><ShieldCheck size={24}/><h2>访问身份</h2><Field label="访问令牌"><input type="password" autoComplete="off" value={token} onChange={e=>setToken(e.target.value)}/></Field><p>令牌由系统管理员分配。身份与项目权限在服务器校验。</p><div><button className="ra-button" onClick={()=>{sessionStorage.removeItem("xingbang-access-token");connected()}}>退出身份</button><button className="ra-button primary" onClick={()=>{sessionStorage.setItem("xingbang-access-token",token);connected()}}>连接</button></div></section></div>}
|
||||
const demoRoles = [
|
||||
{value:"manager", label:"客户经理", description:"查看项目并发起研判,不能修改系统配置。"},
|
||||
{value:"reviewer", label:"发布审核员", description:"审核发布规则与协同成果,不能直接修改配置。"},
|
||||
{value:"admin", label:"配置管理员", description:"维护演示配置、规则与数据连接。"},
|
||||
];
|
||||
|
||||
export function AccessDialog({close,connected}:{close:()=>void;connected:()=>void}){
|
||||
const [role,setRole]=useState(()=>{const stored=sessionStorage.getItem("xingbang-demo-role");return demoRoles.some(item=>item.value===stored)?stored!:"manager"});
|
||||
const selected=demoRoles.find(item=>item.value===role)!;
|
||||
const finish=(next?:string)=>{sessionStorage.removeItem("xingbang-access-token");if(next)sessionStorage.setItem("xingbang-demo-role",next);else sessionStorage.removeItem("xingbang-demo-role");connected()};
|
||||
return <div className="ra-overlay"><section className="ra-confirm" role="dialog" aria-modal="true"><button style={{float:"right"}} title="关闭身份窗口" onClick={close}><X size={18}/></button><ShieldCheck size={24}/><h2>选择演示身份</h2><Field label="当前身份"><select aria-label="演示身份" value={role} onChange={e=>setRole(e.target.value)}>{demoRoles.map(item=><option key={item.value} value={item.value}>{item.label}</option>)}</select></Field><p>{selected.description}</p><p className="ra-note">这是临时演示入口,仅用于业务工作台和模拟数据。通用聊天及文件工具仍需管理员令牌,后续可切换为正式令牌或单点登录。</p><div><button className="ra-button" onClick={()=>finish()}>退出演示身份</button><button className="ra-button primary" onClick={()=>finish(role)}>以此身份进入</button></div></section></div>
|
||||
}
|
||||
|
||||
export function Reviews({items,busy,review}:{items:any[];busy:boolean;review:(id:string,approve:boolean,note:string)=>void}){const [notes,setNotes]=useState<Record<string,string>>({});return <section>{items.map(r=><article className="ra-review-row" key={r.id}><h3>{r.snapshot.name} · {r.snapshot.version}</h3><p className="ra-note">提交人 {r.submitted_by} · {new Date(r.time).toLocaleString()} · {r.status}</p><details className="ra-note"><summary>查看本次提交内容</summary><pre className="ra-result-json">{JSON.stringify(r.snapshot,null,2)}</pre></details>{r.status==="pending"?<><Field label="审核意见"><input value={notes[r.id]||""} onChange={e=>setNotes({...notes,[r.id]:e.target.value})}/></Field><button className="ra-button" disabled={busy||!notes[r.id]?.trim()} onClick={()=>review(r.id,false,notes[r.id])}>驳回</button> <button className="ra-button primary" disabled={busy||!notes[r.id]?.trim()} onClick={()=>review(r.id,true,notes[r.id])}>审核并发布</button></>:<p className="ra-note">{r.reviewed_by}:{r.note}</p>}</article>)}{!items.length&&<p className="ra-empty">暂无审核记录</p>}</section>}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ const META = {connections:{label:"数据连接",icon:Database},cards:{label:"数
|
||||
const STATUS: Record<string,string> = {published:"已发布",draft:"草稿",archived:"已归档"};
|
||||
const API = "/api/risk-agent";
|
||||
async function api(path: string, method="GET", body?: unknown) {
|
||||
const r=await fetch(API+path,{method,headers:{"Content-Type":"application/json",...(typeof window!=="undefined"&&sessionStorage.getItem("xingbang-access-token")?{"Authorization":"Bearer "+sessionStorage.getItem("xingbang-access-token")}:{})},body:body===undefined?undefined:JSON.stringify(body)});
|
||||
const token=typeof window!=="undefined"?sessionStorage.getItem("xingbang-access-token"):null, role=typeof window!=="undefined"?sessionStorage.getItem("xingbang-demo-role"):null;
|
||||
const r=await fetch(API+path,{method,headers:{"Content-Type":"application/json",...(token?{"Authorization":"Bearer "+token}:{}),...(role?{"X-Xingbang-Demo-Role":role}:{})},body:body===undefined?undefined:JSON.stringify(body)});
|
||||
const data=await r.json();
|
||||
if(!r.ok) throw Error(typeof data.detail==="string"?data.detail:JSON.stringify(data.detail||data));
|
||||
return data;
|
||||
|
||||
@@ -12,8 +12,8 @@ const stamp = (value?:string) => value ? new Date(value).toLocaleString("zh-CN",
|
||||
const fmt = (value:any) => value === null || value === undefined ? "—" : String(value);
|
||||
function Badge({value}:{value:string}) {return <span className={`oc-badge ${value}`}>{names[value] || value}</span>;}
|
||||
async function api(path:string, body?:unknown, key?:string) {
|
||||
const token = sessionStorage.getItem("xingbang-access-token");
|
||||
const response = await fetch(ROOT+path, {method:body===undefined?"GET":"POST", cache:"no-store", headers:{"Content-Type":"application/json", ...(token?{Authorization:`Bearer ${token}`} : {}), ...(key?{"Idempotency-Key":key}:{})}, body:body===undefined?undefined:JSON.stringify(body)});
|
||||
const token = sessionStorage.getItem("xingbang-access-token"), role=sessionStorage.getItem("xingbang-demo-role");
|
||||
const response = await fetch(ROOT+path, {method:body===undefined?"GET":"POST", cache:"no-store", headers:{"Content-Type":"application/json", ...(token?{Authorization:`Bearer ${token}`} : {}), ...(role?{"X-Xingbang-Demo-Role":role}:{}), ...(key?{"Idempotency-Key":key}:{})}, body:body===undefined?undefined:JSON.stringify(body)});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(typeof data.detail === "string" ? data.detail : "请求失败,请核对输入与权限");
|
||||
return data;
|
||||
|
||||
@@ -6,7 +6,7 @@ import "../data-access/workspace.css";
|
||||
import "./report.css";
|
||||
|
||||
const BASE="/api/risk-agent";
|
||||
function headers(){const token=sessionStorage.getItem("xingbang-access-token");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{})}}
|
||||
function headers(){const token=sessionStorage.getItem("xingbang-access-token"),role=sessionStorage.getItem("xingbang-demo-role");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{}),...(role?{"X-Xingbang-Demo-Role":role}:{})}}
|
||||
async function call(path:string,body?:unknown){const r=await fetch(BASE+path,{method:body?"POST":"GET",headers:headers(),body:body?JSON.stringify(body):undefined});const data=await r.json();if(!r.ok)throw Error(typeof data.detail==="string"?data.detail:JSON.stringify(data.detail));return data}
|
||||
const states:Record<string,string>={draft:"待复核草稿",final:"已定稿"};
|
||||
export default function Reports(){
|
||||
|
||||
@@ -10,7 +10,7 @@ const ROOT = "/api/risk-agent/coordination";
|
||||
const fields: Record<string, string> = {amount:"额度(万元)",term:"期限(月)",rate:"年化报价(%)",deposit:"保证金(%)",guarantee:"担保条件"};
|
||||
const tabs = [{id:"tasks",name:"风险与补件",icon:ClipboardList},{id:"allocation",name:"主审员安排",icon:Users},{id:"plans",name:"方案与变更",icon:GitCompareArrows},{id:"brief",name:"上会前摘要",icon:FileCheck2}];
|
||||
const newPerson = {id:"",version:0,name:"",industries:[],capacity:5,external_load:0,available:true,conflicts:[]};
|
||||
function headers() {const token=sessionStorage.getItem("xingbang-access-token");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{})};}
|
||||
function headers() {const token=sessionStorage.getItem("xingbang-access-token"),role=sessionStorage.getItem("xingbang-demo-role");return {"Content-Type":"application/json",...(token?{Authorization:`Bearer ${token}`}:{}),...(role?{"X-Xingbang-Demo-Role":role}:{})};}
|
||||
async function api(path:string, body?:unknown, method?:string) {
|
||||
const r=await fetch(path,{method:method||(body?"POST":"GET"),headers:headers(),body:body?JSON.stringify(body):undefined});
|
||||
const d=await r.json();if(!r.ok)throw new Error(typeof d.detail==="string"?d.detail:JSON.stringify(d.detail));return d;
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function XingbangWorkspace() {
|
||||
const [category, setCategory] = useState("rule_omission"), [feedbackNote, setFeedbackNote] = useState(""), [feedbackId, setFeedbackId] = useState("");
|
||||
const [experience, setExperience] = useState({title: "", trigger: "", action: "", exceptions: "", scope: "project", team: "", industry: "", required_field: "collection.evidence", exception_field: "collection.evidence", expires_at: "2027-09-11", condition_field: "collection.dependency", condition_value: "true"});
|
||||
const selected = useRef("");
|
||||
const headers = (json = true) => {const token = sessionStorage.getItem("xingbang-access-token"); return {...(json ? {"Content-Type": "application/json"} : {}), ...(token ? {Authorization: `Bearer ${token}`} : {})};};
|
||||
const headers = (json = true) => {const token = sessionStorage.getItem("xingbang-access-token"), role=sessionStorage.getItem("xingbang-demo-role"); return {...(json ? {"Content-Type": "application/json"} : {}), ...(token ? {Authorization: `Bearer ${token}`} : {}), ...(role ? {"X-Xingbang-Demo-Role":role} : {})};};
|
||||
async function request(path: string, body?: any) {
|
||||
const response = await fetch(`/api/risk-agent/project-assistant${path}`, {method: body === undefined ? "GET" : "POST", headers: {...headers(), ...(/\/(runs|reports)$/.test(path) && body ? {"Idempotency-Key": newRequestId()} : {})}, body: body === undefined ? undefined : JSON.stringify(body)});
|
||||
const data = await response.json();
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function ChatInput() {
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-gray-400/70 mt-2">
|
||||
Powered by DeepSeek · mini OpenClaw v0.1
|
||||
{" · "}<a href="/data-access" className="underline">云端聊天:先连接管理员访问身份</a>
|
||||
{" · "}<a href="/data-access" className="underline">演示身份不开放通用聊天</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user