feat: 发布 PopAdmin 通讯录/相册/媒体拉取完整页与门禁
补齐三页查询/开关/授权/数量布局,并附上线上 static-override 与 jar 热补部署脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
753c276496
commit
40ce6c7dfa
195
deploy/popadmin/_deploy_pull_pages.py
Normal file
195
deploy/popadmin/_deploy_pull_pages.py
Normal file
@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deploy popadmin pull pages static + media-pull hotpatched jar to SG."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
HOST = "47.236.66.9"
|
||||
USER = "root"
|
||||
PEM = Path(os.path.expandvars(r"%USERPROFILE%\.ssh\wyrs_shanghai_deploy.pem"))
|
||||
LOCAL_JS = Path(r"F:\0000wyrs\Aliyun\src\main\resources\static\popadmin\js\popadmin.js")
|
||||
LOCAL_CSS = Path(r"F:\0000wyrs\Aliyun\src\main\resources\static\popadmin\css\popadmin.css")
|
||||
LOCAL_JAR = Path(r"F:\0000wyrs\Aliyun\target\remote-control-1.0.0.media-pull.jar")
|
||||
REMOTE_STATIC = "/opt/wyrs-admin/static-override"
|
||||
REMOTE_JAR = "/opt/wyrs-admin/remote-control-1.0.0.jar"
|
||||
CACHE = "20261102-pullpages"
|
||||
|
||||
|
||||
def run(client: paramiko.SSHClient, cmd: str, timeout: int = 180) -> tuple[int, str, str]:
|
||||
print("$", cmd[:240])
|
||||
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
|
||||
out = stdout.read().decode("utf-8", "replace")
|
||||
err = stderr.read().decode("utf-8", "replace")
|
||||
code = stdout.channel.recv_exit_status()
|
||||
if out.strip():
|
||||
print(out[:2000])
|
||||
if err.strip():
|
||||
print("[stderr]", err[:1000])
|
||||
return code, out, err
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not PEM.is_file():
|
||||
raise SystemExit(f"missing pem {PEM}")
|
||||
for p in (LOCAL_JS, LOCAL_CSS, LOCAL_JAR):
|
||||
if not p.is_file():
|
||||
raise SystemExit(f"missing {p}")
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
# Auto-detect key type (RSA / Ed25519 / ECDSA)
|
||||
key = None
|
||||
for loader in (
|
||||
paramiko.Ed25519Key.from_private_key_file,
|
||||
paramiko.RSAKey.from_private_key_file,
|
||||
paramiko.ECDSAKey.from_private_key_file,
|
||||
):
|
||||
try:
|
||||
key = loader(str(PEM))
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if key is None:
|
||||
raise SystemExit(f"cannot load private key {PEM}")
|
||||
client.connect(HOST, username=USER, pkey=key, timeout=30)
|
||||
sftp = client.open_sftp()
|
||||
|
||||
ts = time.strftime("%Y%m%d%H%M%S")
|
||||
print("=== backup static ===")
|
||||
run(
|
||||
client,
|
||||
f"cp -a {REMOTE_STATIC}/popadmin.js {REMOTE_STATIC}/popadmin.js.bak.pullpages.{ts} && "
|
||||
f"cp -a {REMOTE_STATIC}/popadmin.css {REMOTE_STATIC}/popadmin.css.bak.pullpages.{ts} && "
|
||||
f"cp -a {REMOTE_STATIC}/popadmin.index.html {REMOTE_STATIC}/popadmin.index.html.bak.pullpages.{ts}",
|
||||
)
|
||||
|
||||
print("=== upload static ===")
|
||||
sftp.put(str(LOCAL_JS), f"{REMOTE_STATIC}/popadmin.js")
|
||||
# Prefer remote CSS if it has nav-fix; merge by keeping remote if larger/newer features.
|
||||
# Still upload local CSS then restore nav scroll rules via sed if needed.
|
||||
sftp.put(str(LOCAL_CSS), f"{REMOTE_STATIC}/popadmin.css")
|
||||
|
||||
# bump cache on index
|
||||
idx_cmd = f"""python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
p = Path('{REMOTE_STATIC}/popadmin.index.html')
|
||||
s = p.read_text(encoding='utf-8')
|
||||
import re
|
||||
s2 = re.sub(r'popadmin\\.js\\?v=[^\"\\']+', 'popadmin.js?v={CACHE}', s)
|
||||
s2 = re.sub(r'popadmin\\.css\\?v=[^\"\\']+', 'popadmin.css?v={CACHE}', s2)
|
||||
p.write_text(s2, encoding='utf-8')
|
||||
print('cache', '{CACHE}')
|
||||
print('js', 'popadmin.js?v={CACHE}' in s2)
|
||||
print('css', 'popadmin.css?v={CACHE}' in s2)
|
||||
PY"""
|
||||
run(client, idx_cmd)
|
||||
|
||||
# Ensure sidebar scroll rule exists (from earlier navfix)
|
||||
run(
|
||||
client,
|
||||
f"""python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
p = Path('{REMOTE_STATIC}/popadmin.css')
|
||||
s = p.read_text(encoding='utf-8')
|
||||
needle = '.sidebar nav'
|
||||
rule = '''
|
||||
/* pullpages: keep long nav scrollable */
|
||||
.sidebar {{ display:flex; flex-direction:column; min-height:0; }}
|
||||
.sidebar nav {{ flex:1; overflow-y:auto; overscroll-behavior:contain; }}
|
||||
'''
|
||||
if 'overscroll-behavior:contain' not in s:
|
||||
p.write_text(s + '\\n' + rule, encoding='utf-8')
|
||||
print('css nav scroll appended')
|
||||
else:
|
||||
print('css nav scroll already present')
|
||||
PY""",
|
||||
)
|
||||
|
||||
print("=== markers in deployed js ===")
|
||||
run(
|
||||
client,
|
||||
f"python3 - <<'PY'\n"
|
||||
f"s=open('{REMOTE_STATIC}/popadmin.js',encoding='utf-8',errors='replace').read()\n"
|
||||
f"for k in ['async contacts(','contactsConsented','albumConsented','documentsConsented','用户未授权','mediaPullQId']:\n"
|
||||
f" print(k, s.count(k))\n"
|
||||
f"PY",
|
||||
)
|
||||
|
||||
print("=== deploy jar ===")
|
||||
run(client, f"cp -a {REMOTE_JAR} {REMOTE_JAR}.bak.pullpages.{ts}")
|
||||
remote_tmp = f"/tmp/remote-control-1.0.0.media-pull.{ts}.jar"
|
||||
print("uploading jar...", LOCAL_JAR.stat().st_size)
|
||||
sftp.put(str(LOCAL_JAR), remote_tmp)
|
||||
run(client, f"mv {remote_tmp} {REMOTE_JAR} && chmod 644 {REMOTE_JAR} && ls -lh {REMOTE_JAR}")
|
||||
|
||||
print("=== restart wyrs-admin ===")
|
||||
run(client, "systemctl restart wyrs-admin", timeout=60)
|
||||
# wait health
|
||||
for i in range(30):
|
||||
time.sleep(2)
|
||||
code, out, _ = run(
|
||||
client,
|
||||
"curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/api/admin/auth/login || true",
|
||||
timeout=20,
|
||||
)
|
||||
# login endpoint should be 405/400/200 not 000
|
||||
if out.strip() and out.strip() != "000":
|
||||
print("health http", out.strip(), "attempt", i)
|
||||
break
|
||||
else:
|
||||
print("WARN: health check timed out")
|
||||
|
||||
print("=== verify APIs ===")
|
||||
verify = r'''
|
||||
python3 - <<'PY'
|
||||
import json, os, urllib.request
|
||||
env={}
|
||||
for line in open('/etc/wyrs-admin/app.env',encoding='utf-8',errors='replace'):
|
||||
line=line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line: continue
|
||||
k,v=line.split('=',1); env[k.strip()]=v.strip().strip('"').strip("'")
|
||||
password=env.get('WYRS_ADMIN_PASSWORD') or ''
|
||||
body=json.dumps({'username':'admin','password':password}).encode()
|
||||
req=urllib.request.Request('http://127.0.0.1:8090/api/admin/auth/login',data=body,headers={'Content-Type':'application/json'},method='POST')
|
||||
with urllib.request.urlopen(req,timeout=15) as resp:
|
||||
token=json.loads(resp.read().decode())['data']['token']
|
||||
for url in [
|
||||
'http://127.0.0.1:8090/api/admin/spark/media-pull?userId=1017',
|
||||
'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
|
||||
'http://127.0.0.1:8090/api/admin/spark/contacts/summary',
|
||||
]:
|
||||
req=urllib.request.Request(url,headers={'Authorization':'Bearer '+token})
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=15) as resp:
|
||||
raw=resp.read().decode()
|
||||
print('OK', url, raw[:400])
|
||||
except Exception as e:
|
||||
body=''
|
||||
if hasattr(e,'read'):
|
||||
try: body=e.read().decode()
|
||||
except Exception: pass
|
||||
print('ERR', url, e, body[:300])
|
||||
# PUT pullContacts false should work
|
||||
req=urllib.request.Request(
|
||||
'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
|
||||
data=json.dumps({'pullContacts':False,'count':25}).encode(),
|
||||
headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},
|
||||
method='PUT')
|
||||
with urllib.request.urlopen(req,timeout=15) as resp:
|
||||
print('PUT', resp.read().decode()[:500])
|
||||
PY'''
|
||||
run(client, verify, timeout=60)
|
||||
|
||||
print("=== nginx reload (noop if unchanged) ===")
|
||||
run(client, "nginx -t && nginx -s reload")
|
||||
sftp.close()
|
||||
client.close()
|
||||
print("DEPLOY DONE cache=", CACHE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
143
deploy/popadmin/_hotpatch_media_pull.py
Normal file
143
deploy/popadmin/_hotpatch_media_pull.py
Normal file
@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile Pop media-pull related classes and jar-uf into a deployable fat jar."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"F:\0000wyrs\Aliyun")
|
||||
JAR = ROOT / "target" / "remote-control-1.0.0.jar"
|
||||
OUT_JAR = ROOT / "target" / "remote-control-1.0.0.media-pull.jar"
|
||||
JAVA_HOME = Path(r"C:\Program Files\Eclipse Adoptium\jdk-17.0.20.8-hotspot")
|
||||
JAVAC = JAVA_HOME / "bin" / "javac.exe"
|
||||
JAR_BIN = JAVA_HOME / "bin" / "jar.exe"
|
||||
LOMBOK = ROOT / "scripts" / "lib" / "lombok-1.18.34.jar"
|
||||
|
||||
SRC_FILES = [
|
||||
"com/wyrs/pop/config/PopProperties.java",
|
||||
"com/wyrs/pop/entity/PopUserMedia.java",
|
||||
"com/wyrs/pop/repository/PopUserMediaRepository.java",
|
||||
"com/wyrs/pop/dto/PopMediaPullDtos.java",
|
||||
"com/wyrs/pop/dto/PopSyncDtos.java",
|
||||
"com/wyrs/pop/store/PopMediaPullFileStore.java",
|
||||
"com/wyrs/pop/service/PopMediaPullService.java",
|
||||
"com/wyrs/pop/service/PopSyncService.java",
|
||||
"com/wyrs/pop/controller/PopAdminMediaPullController.java",
|
||||
"com/wyrs/pop/controller/PopClientMediaPullController.java",
|
||||
"com/wyrs/pop/controller/PopClientOpsController.java",
|
||||
"com/wyrs/pop/controller/PopClientSyncController.java",
|
||||
"com/wyrs/pop/controller/PopAdminController.java",
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not JAR.is_file():
|
||||
raise SystemExit(f"missing base jar {JAR}")
|
||||
if not JAVAC.is_file():
|
||||
raise SystemExit(f"missing javac {JAVAC}")
|
||||
if not LOMBOK.is_file():
|
||||
raise SystemExit(f"missing lombok {LOMBOK}")
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix="wyrs-media-pull-"))
|
||||
try:
|
||||
extract = work / "extract"
|
||||
classes_out = work / "classes"
|
||||
extract.mkdir()
|
||||
classes_out.mkdir()
|
||||
print("extracting classpath from fat jar...")
|
||||
with zipfile.ZipFile(JAR, "r") as z:
|
||||
for name in z.namelist():
|
||||
if name.startswith("BOOT-INF/lib/") or name.startswith("BOOT-INF/classes/"):
|
||||
z.extract(name, extract)
|
||||
|
||||
cp_parts = [str(extract / "BOOT-INF" / "classes"), str(LOMBOK)]
|
||||
lib = extract / "BOOT-INF" / "lib"
|
||||
for j in sorted(lib.glob("*.jar")):
|
||||
cp_parts.append(str(j))
|
||||
cp = ";".join(cp_parts)
|
||||
|
||||
src_root = ROOT / "src" / "main" / "java"
|
||||
src_args = []
|
||||
for rel in SRC_FILES:
|
||||
p = src_root / rel
|
||||
if not p.is_file():
|
||||
raise SystemExit(f"missing source {p}")
|
||||
src_args.append(str(p))
|
||||
|
||||
cmd = [
|
||||
str(JAVAC),
|
||||
"-encoding",
|
||||
"UTF-8",
|
||||
"-parameters",
|
||||
"-cp",
|
||||
cp,
|
||||
f"-processorpath",
|
||||
str(LOMBOK),
|
||||
"-d",
|
||||
str(classes_out),
|
||||
*src_args,
|
||||
]
|
||||
print("javac", len(src_args), "files ...")
|
||||
p = subprocess.run(cmd, capture_output=True)
|
||||
log = ROOT / "logs"
|
||||
log.mkdir(parents=True, exist_ok=True)
|
||||
(log / "javac_media_pull_err.txt").write_bytes(p.stderr or b"")
|
||||
(log / "javac_media_pull_out.txt").write_bytes(p.stdout or b"")
|
||||
if p.returncode != 0:
|
||||
print((p.stderr or b"").decode("utf-8", "replace")[:4000])
|
||||
raise SystemExit(f"javac failed code={p.returncode}")
|
||||
|
||||
# Collect produced class files under packages we care about
|
||||
produced = list(classes_out.rglob("*.class"))
|
||||
if not produced:
|
||||
raise SystemExit("no class files produced")
|
||||
print(f"produced {len(produced)} class files")
|
||||
|
||||
# markers
|
||||
markers = [b"contactsConsented", b"pullContacts", b"pullDocuments", b"hasContactsConsent"]
|
||||
blob = b"".join(f.read_bytes() for f in produced)
|
||||
for m in markers:
|
||||
print(f"marker {m!r}:", m in blob)
|
||||
|
||||
shutil.copy2(JAR, OUT_JAR)
|
||||
update_root = work / "update"
|
||||
for f in produced:
|
||||
rel = f.relative_to(classes_out).as_posix()
|
||||
dest = update_root / "BOOT-INF" / "classes" / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(f, dest)
|
||||
|
||||
# jar uf from update_root
|
||||
entries = [
|
||||
"BOOT-INF/classes/" + f.relative_to(classes_out).as_posix() for f in produced
|
||||
]
|
||||
print("jar uf", len(entries), "entries ...")
|
||||
p2 = subprocess.run(
|
||||
[str(JAR_BIN), "uf", str(OUT_JAR), *entries],
|
||||
cwd=str(update_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if p2.returncode != 0:
|
||||
print(p2.stdout)
|
||||
print(p2.stderr)
|
||||
raise SystemExit(f"jar uf failed: {p2.returncode}")
|
||||
|
||||
with zipfile.ZipFile(OUT_JAR, "r") as z:
|
||||
sample = z.read(
|
||||
"BOOT-INF/classes/com/wyrs/pop/service/PopMediaPullService.class"
|
||||
)
|
||||
if b"hasContactsConsent" not in sample and b"contactsConsented" not in sample:
|
||||
raise SystemExit("patched jar missing consent markers")
|
||||
print(f"OK {OUT_JAR} ({OUT_JAR.stat().st_size} bytes)")
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
156
deploy/popadmin/_recover_hotpatch_on_prod_jar.py
Normal file
156
deploy/popadmin/_recover_hotpatch_on_prod_jar.py
Normal file
@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restore prod jar backup, then jar-uf only media-pull classes from patched jar."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
HOST = "47.236.66.9"
|
||||
USER = "root"
|
||||
PEM = Path(os.path.expandvars(r"%USERPROFILE%\.ssh\wyrs_shanghai_deploy.pem"))
|
||||
LOCAL_PATCHED = Path(r"F:\0000wyrs\Aliyun\target\remote-control-1.0.0.media-pull.jar")
|
||||
REMOTE_JAR = "/opt/wyrs-admin/remote-control-1.0.0.jar"
|
||||
REMOTE_WORK = "/tmp/media-pull-uf"
|
||||
# classes relative to BOOT-INF/classes we want to inject
|
||||
CLASS_GLOBS = [
|
||||
"com/wyrs/pop/config/PopProperties.class",
|
||||
"com/wyrs/pop/entity/PopUserMedia.class",
|
||||
"com/wyrs/pop/repository/PopUserMediaRepository.class",
|
||||
"com/wyrs/pop/dto/PopMediaPullDtos*.class",
|
||||
"com/wyrs/pop/dto/PopSyncDtos*.class",
|
||||
"com/wyrs/pop/store/PopMediaPullFileStore*.class",
|
||||
"com/wyrs/pop/service/PopMediaPullService*.class",
|
||||
"com/wyrs/pop/service/PopSyncService.class",
|
||||
"com/wyrs/pop/controller/PopAdminMediaPullController.class",
|
||||
"com/wyrs/pop/controller/PopClientMediaPullController.class",
|
||||
"com/wyrs/pop/controller/PopClientOpsController.class",
|
||||
"com/wyrs/pop/controller/PopClientSyncController.class",
|
||||
"com/wyrs/pop/controller/PopAdminController.class",
|
||||
]
|
||||
|
||||
|
||||
def load_key(pem: Path):
|
||||
for loader in (
|
||||
paramiko.Ed25519Key.from_private_key_file,
|
||||
paramiko.RSAKey.from_private_key_file,
|
||||
paramiko.ECDSAKey.from_private_key_file,
|
||||
):
|
||||
try:
|
||||
return loader(str(pem))
|
||||
except Exception:
|
||||
continue
|
||||
raise SystemExit(f"cannot load {pem}")
|
||||
|
||||
|
||||
def run(client, cmd, timeout=180):
|
||||
print("$", cmd[:300])
|
||||
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
|
||||
out = stdout.read().decode("utf-8", "replace")
|
||||
err = stderr.read().decode("utf-8", "replace")
|
||||
code = stdout.channel.recv_exit_status()
|
||||
if out.strip():
|
||||
print(out[:2500])
|
||||
if err.strip():
|
||||
print("[stderr]", err[:1500])
|
||||
return code, out, err
|
||||
|
||||
|
||||
def main():
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(HOST, username=USER, pkey=load_key(PEM), timeout=30)
|
||||
sftp = client.open_sftp()
|
||||
|
||||
# find bak
|
||||
code, out, _ = run(
|
||||
client,
|
||||
"ls -1t /opt/wyrs-admin/remote-control-1.0.0.jar.bak.pullpages.* 2>/dev/null | head -1",
|
||||
)
|
||||
bak = out.strip()
|
||||
if not bak:
|
||||
raise SystemExit("no bak.pullpages found")
|
||||
print("restore from", bak)
|
||||
run(client, "systemctl stop wyrs-admin || true")
|
||||
run(client, f"cp -a '{bak}' {REMOTE_JAR}")
|
||||
|
||||
# upload patched jar temporarily to extract classes
|
||||
tmp_patched = "/tmp/media-pull-patched-source.jar"
|
||||
print("upload patched jar for class extract...")
|
||||
sftp.put(str(LOCAL_PATCHED), tmp_patched)
|
||||
|
||||
# extract selected classes and jar uf into prod jar
|
||||
script = f"""
|
||||
set -e
|
||||
rm -rf {REMOTE_WORK}
|
||||
mkdir -p {REMOTE_WORK}/BOOT-INF/classes
|
||||
cd {REMOTE_WORK}
|
||||
# extract all matching pop media-pull related classes from patched jar
|
||||
jar xf {tmp_patched} $(jar tf {tmp_patched} | grep -E 'BOOT-INF/classes/com/wyrs/pop/(config/PopProperties|entity/PopUserMedia|repository/PopUserMediaRepository|dto/PopMediaPullDtos|dto/PopSyncDtos|store/PopMediaPullFileStore|service/PopMediaPullService|service/PopSyncService|controller/PopAdminMediaPullController|controller/PopClientMediaPullController|controller/PopClientOpsController|controller/PopClientSyncController|controller/PopAdminController)' | tr '\\n' ' ')
|
||||
# list entries
|
||||
find BOOT-INF/classes -type f -name '*.class' | sort | tee /tmp/media-pull-entries.txt | wc -l
|
||||
# update prod jar
|
||||
jar uf {REMOTE_JAR} $(cat /tmp/media-pull-entries.txt | tr '\\n' ' ')
|
||||
# verify markers
|
||||
jar xf {REMOTE_JAR} BOOT-INF/classes/com/wyrs/pop/service/PopMediaPullService.class
|
||||
strings BOOT-INF/classes/com/wyrs/pop/service/PopMediaPullService.class | grep -E 'hasContactsConsent|contactsConsented|pullContacts' | head
|
||||
ls -lh {REMOTE_JAR}
|
||||
"""
|
||||
run(client, script, timeout=240)
|
||||
|
||||
run(client, "systemctl start wyrs-admin")
|
||||
ok = False
|
||||
for i in range(40):
|
||||
time.sleep(3)
|
||||
code, out, _ = run(
|
||||
client,
|
||||
"curl -sS -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/api/admin/auth/login || true",
|
||||
timeout=20,
|
||||
)
|
||||
if out.strip() not in ("", "000"):
|
||||
print("UP", out.strip(), "attempt", i)
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
run(client, "journalctl -u wyrs-admin -n 60 --no-pager | tail -60")
|
||||
raise SystemExit("service failed to come up")
|
||||
|
||||
# API verify
|
||||
verify = r'''
|
||||
python3 - <<'PY'
|
||||
import json, urllib.request
|
||||
env={}
|
||||
for line in open('/etc/wyrs-admin/app.env',encoding='utf-8',errors='replace'):
|
||||
line=line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line: continue
|
||||
k,v=line.split('=',1); env[k.strip()]=v.strip().strip('"').strip("'")
|
||||
password=env.get('WYRS_ADMIN_PASSWORD') or ''
|
||||
body=json.dumps({'username':'admin','password':password}).encode()
|
||||
req=urllib.request.Request('http://127.0.0.1:8090/api/admin/auth/login',data=body,headers={'Content-Type':'application/json'},method='POST')
|
||||
with urllib.request.urlopen(req,timeout=20) as resp:
|
||||
token=json.loads(resp.read().decode())['data']['token']
|
||||
for url in [
|
||||
'http://127.0.0.1:8090/api/admin/spark/media-pull?userId=1017',
|
||||
'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
|
||||
]:
|
||||
req=urllib.request.Request(url,headers={'Authorization':'Bearer '+token})
|
||||
with urllib.request.urlopen(req,timeout=20) as resp:
|
||||
print('GET', url, resp.read().decode()[:500])
|
||||
req=urllib.request.Request(
|
||||
'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
|
||||
data=json.dumps({'pullContacts':False,'pullDocuments':False,'count':25}).encode(),
|
||||
headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},
|
||||
method='PUT')
|
||||
with urllib.request.urlopen(req,timeout=20) as resp:
|
||||
print('PUT', resp.read().decode()[:600])
|
||||
PY'''
|
||||
run(client, verify, timeout=60)
|
||||
sftp.close()
|
||||
client.close()
|
||||
print("RECOVER+HOTPATCH DONE")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
100
deploy/popadmin/_verify_pull_apis.py
Normal file
100
deploy/popadmin/_verify_pull_apis.py
Normal file
@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, urllib.request, urllib.error
|
||||
from pathlib import Path
|
||||
import paramiko
|
||||
|
||||
HOST = "47.236.66.9"
|
||||
PEM = Path(os.path.expandvars(r"%USERPROFILE%\.ssh\wyrs_shanghai_deploy.pem"))
|
||||
|
||||
|
||||
def load_key(pem):
|
||||
for loader in (
|
||||
paramiko.Ed25519Key.from_private_key_file,
|
||||
paramiko.RSAKey.from_private_key_file,
|
||||
paramiko.ECDSAKey.from_private_key_file,
|
||||
):
|
||||
try:
|
||||
return loader(str(pem))
|
||||
except Exception:
|
||||
continue
|
||||
raise SystemExit("key fail")
|
||||
|
||||
|
||||
def main():
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect(HOST, username="root", pkey=load_key(PEM), timeout=30)
|
||||
script = r'''
|
||||
python3 - <<'PY'
|
||||
import json, urllib.request, urllib.error
|
||||
env={}
|
||||
for line in open('/etc/wyrs-admin/app.env',encoding='utf-8',errors='replace'):
|
||||
line=line.strip()
|
||||
if not line or line.startswith('#') or '=' not in line: continue
|
||||
k,v=line.split('=',1); env[k.strip()]=v.strip().strip('"').strip("'")
|
||||
password=env.get('WYRS_ADMIN_PASSWORD') or ''
|
||||
# try both username/password field names
|
||||
for body in (
|
||||
{'username':'admin','password':password},
|
||||
{'email':'admin','password':password},
|
||||
):
|
||||
raw=json.dumps(body).encode()
|
||||
req=urllib.request.Request('http://127.0.0.1:8090/api/admin/auth/login',data=raw,headers={'Content-Type':'application/json'},method='POST')
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=20) as resp:
|
||||
data=json.loads(resp.read().decode())
|
||||
print('LOGIN_OK', body.keys(), list(data.keys()), data.get('message'), type(data.get('data')))
|
||||
token=(data.get('data') or {}).get('token') if isinstance(data.get('data'), dict) else None
|
||||
if token:
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
print('LOGIN_ERR', body.keys(), e.code, e.read()[:300])
|
||||
token=None
|
||||
if not token:
|
||||
raise SystemExit('no token')
|
||||
|
||||
def get(url):
|
||||
req=urllib.request.Request(url,headers={'Authorization':'Bearer '+token})
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=20) as resp:
|
||||
print('GET', url, resp.read().decode()[:700])
|
||||
except urllib.error.HTTPError as e:
|
||||
print('GET_ERR', url, e.code, e.read()[:500])
|
||||
|
||||
get('http://127.0.0.1:8090/api/admin/spark/media-pull?userId=1017')
|
||||
get('http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local')
|
||||
get('http://127.0.0.1:8090/api/admin/spark/contacts/summary')
|
||||
get('http://127.0.0.1:8090/api/admin/spark/media/summary')
|
||||
get('http://127.0.0.1:8090/api/admin/spark/media?email=spark.test.01@wyrs.test.local&kind=document&page=0&size=5')
|
||||
get('http://127.0.0.1:8090/api/admin/spark/contacts?userId=1017&page=0&size=5')
|
||||
|
||||
# PUT toggles
|
||||
payload=json.dumps({'pullContacts':False,'pullDocuments':False,'pullPhotos':False,'pullVideos':False,'enabled':False,'count':25}).encode()
|
||||
req=urllib.request.Request(
|
||||
'http://127.0.0.1:8090/api/admin/spark/media-pull?email=spark.test.01@wyrs.test.local',
|
||||
data=payload,
|
||||
headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'},
|
||||
method='PUT')
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=20) as resp:
|
||||
print('PUT', resp.read().decode()[:800])
|
||||
except urllib.error.HTTPError as e:
|
||||
print('PUT_ERR', e.code, e.read()[:500])
|
||||
|
||||
# static markers
|
||||
import pathlib
|
||||
s=pathlib.Path('/opt/wyrs-admin/static-override/popadmin.js').read_text(encoding='utf-8',errors='replace')
|
||||
idx=pathlib.Path('/opt/wyrs-admin/static-override/popadmin.index.html').read_text(encoding='utf-8',errors='replace')
|
||||
print('STATIC', 'contactsConsented' in s, '用户未授权' in s, '20261102-pullpages' in idx)
|
||||
print('INDEX_JS', [line for line in idx.splitlines() if 'popadmin.js' in line][:2])
|
||||
PY'''
|
||||
_, stdout, stderr = client.exec_command(script, timeout=60)
|
||||
print(stdout.read().decode("utf-8", "replace"))
|
||||
err = stderr.read().decode("utf-8", "replace")
|
||||
if err.strip():
|
||||
print("STDERR", err[:2000])
|
||||
client.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
222
deploy/popadmin/popadmin.css
Normal file
222
deploy/popadmin/popadmin.css
Normal file
@ -0,0 +1,222 @@
|
||||
:root {
|
||||
/* layers */
|
||||
--bg: #070b10;
|
||||
--bg-elevated: #0a1016;
|
||||
--sidebar: #0c141c;
|
||||
--card: rgba(16, 26, 36, 0.93);
|
||||
--surface: #091018;
|
||||
|
||||
/* borders / text */
|
||||
--border: rgba(56, 189, 248, 0.18);
|
||||
--border-strong: rgba(56, 189, 248, 0.4);
|
||||
--text: #e8f1f8;
|
||||
--muted: #7a93a8;
|
||||
|
||||
/* accent — popadmin cyan */
|
||||
--accent: #38bdf8;
|
||||
--accent2: #22d3ee;
|
||||
--accent-soft: rgba(56, 189, 248, 0.09);
|
||||
--accent-glow: rgba(56, 189, 248, 0.16);
|
||||
--on-accent: #041018;
|
||||
|
||||
/* semantic */
|
||||
--success: #34d399;
|
||||
--danger: #f87171;
|
||||
--warning: #fbbf24;
|
||||
|
||||
/* shape / depth / space */
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--shadow-sm: 0 4px 18px rgba(0, 0, 0, 0.28);
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.4);
|
||||
--shadow-glow: 0 0 36px var(--accent-glow);
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
|
||||
--gradient-accent: linear-gradient(135deg, #0ea5e9, var(--accent2));
|
||||
--gradient-page:
|
||||
radial-gradient(ellipse 80% 50% at 20% -10%, rgba(56,189,248,0.15), transparent 55%),
|
||||
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(34,211,238,0.09), transparent 50%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: "IBM Plex Sans", "Segoe UI", "PingFang SC", sans-serif;
|
||||
background: var(--gradient-page);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
|
||||
}
|
||||
.login-card {
|
||||
width: 380px; max-width: 100%; padding: 40px; background: var(--card);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow), var(--shadow-glow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.login-card h1 {
|
||||
font-size: 24px; font-weight: 700; letter-spacing: -0.02em;
|
||||
background: linear-gradient(110deg, var(--accent), #a5f3fc);
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.login-sub { color: var(--muted); font-size: 13px; margin: 8px 0 24px; line-height: 1.45; }
|
||||
.login-card input {
|
||||
width: 100%; padding: 12px 14px; margin-bottom: 12px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text); outline: none;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.login-card input:focus {
|
||||
border-color: var(--border-strong); box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.login-card button:not(.secondary) {
|
||||
width: 100%; padding: 12px; border: none; border-radius: var(--radius); cursor: pointer;
|
||||
background: var(--gradient-accent); color: var(--on-accent); font-weight: 700;
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
}
|
||||
.login-card button.secondary, button.secondary, .btn.secondary {
|
||||
width: 100%; padding: 12px;
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--muted); cursor: pointer; font-weight: 500;
|
||||
}
|
||||
.error { color: var(--danger); font-size: 13px; margin-top: 10px; min-height: 18px; }
|
||||
.hint { color: var(--muted); font-size: 12px; margin-top: 16px; line-height: 1.5; }
|
||||
.hint a { color: var(--accent); }
|
||||
|
||||
.app { display: flex; min-height: 100vh; height: 100vh; max-height: 100vh; overflow: hidden; }
|
||||
/* 桌面默认隐藏;≤768px 由 /shared/admin-responsive.css 显示抽屉 */
|
||||
.sidebar-toggle,
|
||||
.sidebar-backdrop { display: none; }
|
||||
.sidebar {
|
||||
width: 228px; flex-shrink: 0; background: var(--sidebar); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.brand {
|
||||
padding: 22px 20px; font-weight: 800; letter-spacing: 0.14em;
|
||||
color: var(--accent); font-size: 13px; border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, var(--accent-soft), transparent);
|
||||
text-shadow: 0 0 16px var(--accent-glow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 菜单项很多时必须可滚,否则中下部「通讯录/相册/媒体拉取」会被裁切表现为点击无反应 */
|
||||
.sidebar nav { flex: 1; padding: 12px 0; overflow-y: auto; min-height: 0; overscroll-behavior: contain; }
|
||||
.sidebar nav a {
|
||||
display: block; padding: 10px 20px; margin: 2px 10px;
|
||||
color: var(--muted); text-decoration: none; font-size: 13.5px; cursor: pointer;
|
||||
border-radius: var(--radius-sm); border-left: 2px solid transparent;
|
||||
transition: color .12s, background .12s;
|
||||
}
|
||||
.sidebar nav a:hover { color: var(--text); background: rgba(255,255,255,0.03); }
|
||||
.sidebar nav a.active {
|
||||
color: var(--accent); background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px rgba(56,189,248,0.1);
|
||||
}
|
||||
.logout-btn {
|
||||
margin: 12px; padding: 10px; background: transparent; border: 1px solid var(--border);
|
||||
color: var(--muted); border-radius: var(--radius); cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.logout-btn:hover { color: var(--text); border-color: var(--border-strong); }
|
||||
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.topbar {
|
||||
padding: 16px 24px; border-bottom: 1px solid var(--border);
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
background: linear-gradient(180deg, rgba(16,26,36,0.8), transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.topbar h2 { font-size: 18px; font-weight: 650; }
|
||||
.topbar span { color: var(--muted); font-size: 13px; }
|
||||
.content { padding: 24px; flex: 1; overflow: auto; background: var(--bg-elevated); }
|
||||
|
||||
.cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 14px; margin-bottom: 20px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
padding: 18px; box-shadow: var(--shadow-sm); position: relative; overflow: hidden;
|
||||
}
|
||||
.card::before {
|
||||
content: ""; position: absolute; inset: 0 0 auto 0; height: 1px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent); opacity: .4;
|
||||
}
|
||||
.card .label { color: var(--muted); font-size: 12px; letter-spacing: 0.03em; }
|
||||
.card .value { font-size: 26px; font-weight: 700; margin-top: 6px; color: var(--accent); }
|
||||
.note {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
padding: 16px; color: var(--muted); font-size: 13px; line-height: 1.6; margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
|
||||
.toolbar input, .toolbar select, .form-grid input, .form-grid textarea, .form-grid select {
|
||||
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); color: var(--text);
|
||||
}
|
||||
.toolbar button, .btn {
|
||||
padding: 8px 14px; border: none; border-radius: var(--radius); cursor: pointer; font-size: 13px;
|
||||
background: var(--gradient-accent); color: var(--on-accent); font-weight: 600; width: auto;
|
||||
}
|
||||
.btn.secondary, .toolbar button.secondary {
|
||||
background: transparent; border: 1px solid var(--border); color: var(--muted); width: auto;
|
||||
}
|
||||
.btn.danger { background: transparent; border: 1px solid rgba(248,113,113,0.4); color: var(--danger); }
|
||||
.btn.warn { background: transparent; border: 1px solid rgba(251,191,36,0.45); color: var(--warning); }
|
||||
.section-block { margin-bottom: 16px; }
|
||||
.section-block h3 { margin: 0 0 8px; font-size: 14px; color: var(--accent); font-weight: 650; }
|
||||
.action-bar { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 16px; }
|
||||
.flag-bad { color: var(--danger); font-weight: 600; }
|
||||
.flag-warn { color: var(--warning); font-weight: 600; }
|
||||
.flag-ok { color: var(--success); }
|
||||
|
||||
table {
|
||||
width: 100%; border-collapse: collapse; background: var(--card);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid rgba(122,147,168,0.14); font-size: 13px; }
|
||||
th { background: var(--accent-soft); color: var(--accent); font-weight: 650; font-size: 12px; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; max-width: 720px; margin-bottom: 16px; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
.form-grid textarea { min-height: 80px; resize: vertical; }
|
||||
.tag {
|
||||
display: inline-block; padding: 2px 8px; border-radius: var(--radius-sm); font-size: 12px;
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
.tag.off { background: rgba(122,147,168,0.15); color: var(--muted); }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; margin-right: 4px; }
|
||||
.media-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; margin-top: 12px;
|
||||
}
|
||||
.media-card {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.media-card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; background: var(--surface); }
|
||||
.media-meta { padding: 8px; font-size: 11px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.switch-row { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; }
|
||||
.switch-row input { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
|
||||
|
||||
.detail-dl {
|
||||
display: grid; grid-template-columns: 110px 1fr; gap: 6px 14px; margin-top: 10px; font-size: 13px;
|
||||
}
|
||||
.detail-dl dt { color: var(--muted); }
|
||||
.detail-dl dd { color: var(--text); margin: 0; word-break: break-all; }
|
||||
|
||||
/* 窄屏侧栏/表格/触控见 /shared/admin-responsive.css */
|
||||
@ -1890,14 +1890,22 @@ const pages = {
|
||||
}
|
||||
const on = !!st.pullContacts;
|
||||
const consented = !!st.contactsConsented;
|
||||
const toggleDisabled = !consented && !on ? 'disabled' : '';
|
||||
const gateNote = !consented
|
||||
? (on
|
||||
? '<div class="note" style="margin-top:8px;color:var(--danger)">开关已开但用户<strong>未授权</strong>:服务端/客户端均不会实际拉取,直至 APK 知情同意上报。</div>'
|
||||
: '<div class="note" style="margin-top:8px;color:var(--muted)">用户未授权,拉取开关不可开启。请等待客户端知情同意后再开。</div>')
|
||||
: (on
|
||||
? '<div class="note" style="margin-top:8px">已授权且开关开启:满足门禁后客户端可同步。</div>'
|
||||
: '');
|
||||
panel.innerHTML = `
|
||||
<table>
|
||||
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>联系人数</th><th>数量上限</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" id="contactsPullToggle" ${on ? 'checked' : ''}>
|
||||
<label class="switch-row" title="${consented ? '' : '用户未授权,无法开启拉取'}">
|
||||
<input type="checkbox" id="contactsPullToggle" ${on ? 'checked' : ''} ${toggleDisabled}>
|
||||
<span id="contactsPullLabel">${on ? '已开启' : '关闭'}</span>
|
||||
</label>
|
||||
</td>
|
||||
@ -1912,6 +1920,7 @@ const pages = {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${gateNote}
|
||||
<div class="note" style="margin-top:10px">共 ${esc(contactTotal)} 条 · 布局:开关→授权→ID · 授权由 APK 知情同意后经 email_user JWT 上报 · 数量 20–30</div>
|
||||
<table>
|
||||
<thead><tr><th>姓名</th><th>备注名</th><th>电话</th><th>同步时间</th></tr></thead>
|
||||
@ -1925,6 +1934,15 @@ const pages = {
|
||||
</table>`;
|
||||
$('contactsPullToggle').onchange = async () => {
|
||||
const enabled = $('contactsPullToggle').checked;
|
||||
if (enabled && !consented) {
|
||||
$('contactsPullToggle').checked = false;
|
||||
alert('用户未授权,无法开启通讯录拉取');
|
||||
return;
|
||||
}
|
||||
if (enabled && !confirm('确认开启该用户的通讯录拉取?仍须用户已授权才会实际上传。')) {
|
||||
$('contactsPullToggle').checked = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('/spark/media-pull?email=' + encodeURIComponent(st.email), {
|
||||
method: 'PUT',
|
||||
@ -2033,14 +2051,20 @@ const pages = {
|
||||
(m) => m.mediaType === 'image' || m.mediaType === 'video',
|
||||
);
|
||||
const albumConsented = !!st.albumConsented;
|
||||
const albumToggleDisabled = !albumConsented && !albumOn ? 'disabled' : '';
|
||||
const albumGateNote = !albumConsented
|
||||
? (albumOn
|
||||
? '<div class="note" style="margin-top:8px;color:var(--danger)">开关已开但用户<strong>未授权</strong>:不会实际拉取相册,直至 APK 知情同意上报。</div>'
|
||||
: '<div class="note" style="margin-top:8px;color:var(--muted)">用户未授权,相册拉取开关不可开启。</div>')
|
||||
: '';
|
||||
panel.innerHTML = `
|
||||
<table>
|
||||
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>相册条数</th><th>数量上限</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" id="albumPullToggle" ${albumOn ? 'checked' : ''}>
|
||||
<label class="switch-row" title="${albumConsented ? '' : '用户未授权,无法开启拉取'}">
|
||||
<input type="checkbox" id="albumPullToggle" ${albumOn ? 'checked' : ''} ${albumToggleDisabled}>
|
||||
<span id="albumPullLabel">${albumOn ? '已开启' : '关闭'}</span>
|
||||
</label>
|
||||
</td>
|
||||
@ -2055,6 +2079,7 @@ const pages = {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${albumGateNote}
|
||||
<div class="note" style="margin-top:10px">布局:开关→授权→ID · 最新优先 · 数量 20–30 · 授权由 APK 同意后上报</div>
|
||||
<div class="media-grid">
|
||||
${visuals.length
|
||||
@ -2072,6 +2097,15 @@ const pages = {
|
||||
</div>`;
|
||||
$('albumPullToggle').onchange = async () => {
|
||||
const enabled = $('albumPullToggle').checked;
|
||||
if (enabled && !albumConsented) {
|
||||
$('albumPullToggle').checked = false;
|
||||
alert('用户未授权,无法开启相册拉取');
|
||||
return;
|
||||
}
|
||||
if (enabled && !confirm('确认开启该用户的相册拉取?仍须用户已授权才会实际上传。')) {
|
||||
$('albumPullToggle').checked = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = enabled
|
||||
? { enabled: true, pullPhotos: true, pullVideos: true }
|
||||
@ -2202,14 +2236,20 @@ const pages = {
|
||||
}
|
||||
const docsOn = !!(st.enabled && st.pullDocuments);
|
||||
const docsConsented = !!st.documentsConsented;
|
||||
const docsToggleDisabled = !docsConsented && !docsOn ? 'disabled' : '';
|
||||
const docsGateNote = !docsConsented
|
||||
? (docsOn
|
||||
? '<div class="note" style="margin-bottom:10px;color:var(--danger)">开关已开但用户<strong>未授权</strong>:不会实际拉取文档,直至 APK 知情同意上报。</div>'
|
||||
: '<div class="note" style="margin-bottom:10px;color:var(--muted)">用户未授权,文档拉取开关不可开启。</div>')
|
||||
: '';
|
||||
$('mediaPullPanel').innerHTML = `
|
||||
<table style="margin-bottom:14px">
|
||||
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>文档数</th><th>数量上限</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label class="switch-row">
|
||||
<input type="checkbox" id="mediaPullToggle" ${docsOn ? 'checked' : ''}>
|
||||
<label class="switch-row" title="${docsConsented ? '' : '用户未授权,无法开启拉取'}">
|
||||
<input type="checkbox" id="mediaPullToggle" ${docsOn ? 'checked' : ''} ${docsToggleDisabled}>
|
||||
<span id="mediaPullLabel">${docsOn ? '已开启' : '关闭'}</span>
|
||||
</label>
|
||||
</td>
|
||||
@ -2224,6 +2264,7 @@ const pages = {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
${docsGateNote}
|
||||
<div class="note" style="margin-bottom:10px">布局:开关→授权→ID · 文档最新优先 · 授权由 APK 同意后经 JWT 上报;相册开关在「用户相册」页。</div>
|
||||
<div class="toolbar" style="margin-bottom:10px;gap:8px;flex-wrap:wrap">
|
||||
<span class="note" style="margin:0">文档筛选</span>
|
||||
@ -2285,6 +2326,15 @@ const pages = {
|
||||
|
||||
$('mediaPullToggle').onchange = async () => {
|
||||
const enabled = $('mediaPullToggle').checked;
|
||||
if (enabled && !docsConsented) {
|
||||
$('mediaPullToggle').checked = false;
|
||||
alert('用户未授权,无法开启文档拉取');
|
||||
return;
|
||||
}
|
||||
if (enabled && !confirm('确认开启该用户的文档拉取?仍须用户已授权才会实际上传。')) {
|
||||
$('mediaPullToggle').checked = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = enabled
|
||||
? { enabled: true, pullDocuments: true }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user