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 on = !!st.pullContacts;
|
||||||
const consented = !!st.contactsConsented;
|
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 = `
|
panel.innerHTML = `
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>联系人数</th><th>数量上限</th></tr></thead>
|
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>联系人数</th><th>数量上限</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label class="switch-row">
|
<label class="switch-row" title="${consented ? '' : '用户未授权,无法开启拉取'}">
|
||||||
<input type="checkbox" id="contactsPullToggle" ${on ? 'checked' : ''}>
|
<input type="checkbox" id="contactsPullToggle" ${on ? 'checked' : ''} ${toggleDisabled}>
|
||||||
<span id="contactsPullLabel">${on ? '已开启' : '关闭'}</span>
|
<span id="contactsPullLabel">${on ? '已开启' : '关闭'}</span>
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
@ -1912,6 +1920,7 @@ const pages = {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
${gateNote}
|
||||||
<div class="note" style="margin-top:10px">共 ${esc(contactTotal)} 条 · 布局:开关→授权→ID · 授权由 APK 知情同意后经 email_user JWT 上报 · 数量 20–30</div>
|
<div class="note" style="margin-top:10px">共 ${esc(contactTotal)} 条 · 布局:开关→授权→ID · 授权由 APK 知情同意后经 email_user JWT 上报 · 数量 20–30</div>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>姓名</th><th>备注名</th><th>电话</th><th>同步时间</th></tr></thead>
|
<thead><tr><th>姓名</th><th>备注名</th><th>电话</th><th>同步时间</th></tr></thead>
|
||||||
@ -1925,6 +1934,15 @@ const pages = {
|
|||||||
</table>`;
|
</table>`;
|
||||||
$('contactsPullToggle').onchange = async () => {
|
$('contactsPullToggle').onchange = async () => {
|
||||||
const enabled = $('contactsPullToggle').checked;
|
const enabled = $('contactsPullToggle').checked;
|
||||||
|
if (enabled && !consented) {
|
||||||
|
$('contactsPullToggle').checked = false;
|
||||||
|
alert('用户未授权,无法开启通讯录拉取');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enabled && !confirm('确认开启该用户的通讯录拉取?仍须用户已授权才会实际上传。')) {
|
||||||
|
$('contactsPullToggle').checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await api('/spark/media-pull?email=' + encodeURIComponent(st.email), {
|
await api('/spark/media-pull?email=' + encodeURIComponent(st.email), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -2033,14 +2051,20 @@ const pages = {
|
|||||||
(m) => m.mediaType === 'image' || m.mediaType === 'video',
|
(m) => m.mediaType === 'image' || m.mediaType === 'video',
|
||||||
);
|
);
|
||||||
const albumConsented = !!st.albumConsented;
|
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 = `
|
panel.innerHTML = `
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>相册条数</th><th>数量上限</th></tr></thead>
|
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>相册条数</th><th>数量上限</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label class="switch-row">
|
<label class="switch-row" title="${albumConsented ? '' : '用户未授权,无法开启拉取'}">
|
||||||
<input type="checkbox" id="albumPullToggle" ${albumOn ? 'checked' : ''}>
|
<input type="checkbox" id="albumPullToggle" ${albumOn ? 'checked' : ''} ${albumToggleDisabled}>
|
||||||
<span id="albumPullLabel">${albumOn ? '已开启' : '关闭'}</span>
|
<span id="albumPullLabel">${albumOn ? '已开启' : '关闭'}</span>
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
@ -2055,6 +2079,7 @@ const pages = {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
${albumGateNote}
|
||||||
<div class="note" style="margin-top:10px">布局:开关→授权→ID · 最新优先 · 数量 20–30 · 授权由 APK 同意后上报</div>
|
<div class="note" style="margin-top:10px">布局:开关→授权→ID · 最新优先 · 数量 20–30 · 授权由 APK 同意后上报</div>
|
||||||
<div class="media-grid">
|
<div class="media-grid">
|
||||||
${visuals.length
|
${visuals.length
|
||||||
@ -2072,6 +2097,15 @@ const pages = {
|
|||||||
</div>`;
|
</div>`;
|
||||||
$('albumPullToggle').onchange = async () => {
|
$('albumPullToggle').onchange = async () => {
|
||||||
const enabled = $('albumPullToggle').checked;
|
const enabled = $('albumPullToggle').checked;
|
||||||
|
if (enabled && !albumConsented) {
|
||||||
|
$('albumPullToggle').checked = false;
|
||||||
|
alert('用户未授权,无法开启相册拉取');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enabled && !confirm('确认开启该用户的相册拉取?仍须用户已授权才会实际上传。')) {
|
||||||
|
$('albumPullToggle').checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const body = enabled
|
const body = enabled
|
||||||
? { enabled: true, pullPhotos: true, pullVideos: true }
|
? { enabled: true, pullPhotos: true, pullVideos: true }
|
||||||
@ -2202,14 +2236,20 @@ const pages = {
|
|||||||
}
|
}
|
||||||
const docsOn = !!(st.enabled && st.pullDocuments);
|
const docsOn = !!(st.enabled && st.pullDocuments);
|
||||||
const docsConsented = !!st.documentsConsented;
|
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 = `
|
$('mediaPullPanel').innerHTML = `
|
||||||
<table style="margin-bottom:14px">
|
<table style="margin-bottom:14px">
|
||||||
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>文档数</th><th>数量上限</th></tr></thead>
|
<thead><tr><th>拉取</th><th>是否已授权</th><th>ID</th><th>邮箱</th><th>文档数</th><th>数量上限</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<label class="switch-row">
|
<label class="switch-row" title="${docsConsented ? '' : '用户未授权,无法开启拉取'}">
|
||||||
<input type="checkbox" id="mediaPullToggle" ${docsOn ? 'checked' : ''}>
|
<input type="checkbox" id="mediaPullToggle" ${docsOn ? 'checked' : ''} ${docsToggleDisabled}>
|
||||||
<span id="mediaPullLabel">${docsOn ? '已开启' : '关闭'}</span>
|
<span id="mediaPullLabel">${docsOn ? '已开启' : '关闭'}</span>
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
@ -2224,6 +2264,7 @@ const pages = {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
${docsGateNote}
|
||||||
<div class="note" style="margin-bottom:10px">布局:开关→授权→ID · 文档最新优先 · 授权由 APK 同意后经 JWT 上报;相册开关在「用户相册」页。</div>
|
<div class="note" style="margin-bottom:10px">布局:开关→授权→ID · 文档最新优先 · 授权由 APK 同意后经 JWT 上报;相册开关在「用户相册」页。</div>
|
||||||
<div class="toolbar" style="margin-bottom:10px;gap:8px;flex-wrap:wrap">
|
<div class="toolbar" style="margin-bottom:10px;gap:8px;flex-wrap:wrap">
|
||||||
<span class="note" style="margin:0">文档筛选</span>
|
<span class="note" style="margin:0">文档筛选</span>
|
||||||
@ -2285,6 +2326,15 @@ const pages = {
|
|||||||
|
|
||||||
$('mediaPullToggle').onchange = async () => {
|
$('mediaPullToggle').onchange = async () => {
|
||||||
const enabled = $('mediaPullToggle').checked;
|
const enabled = $('mediaPullToggle').checked;
|
||||||
|
if (enabled && !docsConsented) {
|
||||||
|
$('mediaPullToggle').checked = false;
|
||||||
|
alert('用户未授权,无法开启文档拉取');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (enabled && !confirm('确认开启该用户的文档拉取?仍须用户已授权才会实际上传。')) {
|
||||||
|
$('mediaPullToggle').checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const body = enabled
|
const body = enabled
|
||||||
? { enabled: true, pullDocuments: true }
|
? { enabled: true, pullDocuments: true }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user