Commit 12434efb authored by 郭阔's avatar 郭阔

feat(panel): site-shared login model and list fixes

- index: unscheduled sources no longer link to /source (was 404); show 未排程 badge
- index: sources with toggle ON sort first
- logins page: split into per-source login-state table (site-shared instance
  column, per-source test via new /login-test-src route) + site-credential table
- platform_layer: skill_sources() exposes method; login_sites() drops direct
  sources; new login_source_rows()
parent 3245cbf2
...@@ -164,7 +164,7 @@ def skill_config_path(): ...@@ -164,7 +164,7 @@ def skill_config_path():
def skill_sources(): def skill_sources():
"""返回 {id: {"name":…, "enabled": bool}};技能未安装返回 {}。""" """返回 {id: {"name":…, "enabled": bool, "method": str}};技能未安装返回 {}。"""
cfg = skill_config_path() cfg = skill_config_path()
if not cfg.is_file(): if not cfg.is_file():
return {} return {}
...@@ -173,7 +173,11 @@ def skill_sources(): ...@@ -173,7 +173,11 @@ def skill_sources():
except (OSError, ValueError): except (OSError, ValueError):
return {} return {}
return { return {
s.get("id", ""): {"name": s.get("name", ""), "enabled": bool(s.get("enabled", True))} s.get("id", ""): {
"name": s.get("name", ""),
"enabled": bool(s.get("enabled", True)),
"method": s.get("method", "browser"),
}
for s in data.get("sources", []) if s.get("id") for s in data.get("sources", []) if s.get("id")
} }
...@@ -206,13 +210,20 @@ def login_conf_path(source): ...@@ -206,13 +210,20 @@ def login_conf_path(source):
def login_sites(): def login_sites():
"""站点 → 该站点覆盖的已接入源列表(来自技能源 ∪ 调度)。""" """站点 → 该站点覆盖的已接入源列表(来自技能源 ∪ 调度)。
direct 直采源(如 the-conversation / wccftech-news)没有登录环节,
不进站点登录视图。
"""
sites = {} sites = {}
try: try:
names = set(skill_sources()) | set(list_schedules()) skills = skill_sources()
names = set(skills) | set(list_schedules())
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
names = set() skills, names = {}, set()
for name in sorted(names): for name in sorted(names):
if skills.get(name, {}).get("method") == "direct":
continue
sites.setdefault(login_site(name), []).append(name) sites.setdefault(login_site(name), []).append(name)
return sites return sites
...@@ -293,6 +304,35 @@ def login_rows(): ...@@ -293,6 +304,35 @@ def login_rows():
return rows return rows
def login_source_rows():
"""站点登录视图(按源拆开):每个浏览器源一行,展示其所属站点共享实例的登录上下文。
登录态按站点共享(同站多频道共用实例与 profile ~/.news-harvester/profiles/<站点>/);
凭据与配方也是站点级(站点 = 源名第一段)。direct 直采源无登录环节,不列出。
"""
creds = get_logins()
recipes = login_recipes()
skills = skill_sources()
names = set(skills) | set(list_schedules())
rows = []
for name in sorted(names):
if skills.get(name, {}).get("method") == "direct":
continue
site = login_site(name)
c = creds.get(site, {})
rows.append({
"source": name,
"display": skills.get(name, {}).get("name") or name,
"site": site,
"user": c.get("user", ""),
"configured": bool(c.get("configured")),
"has_recipe": site in recipes or name in recipes,
"has_profile": (Path.home() / ".news-harvester" / "profiles" / site).is_dir(),
"enabled": skills.get(name, {}).get("enabled", False),
})
return rows
# ── 组合视图 ──────────────────────────────────────────────── # ── 组合视图 ────────────────────────────────────────────────
def source_rows(log_dir=None): def source_rows(log_dir=None):
"""面板源列表全集:已装技能源 ∪ 已有调度。 """面板源列表全集:已装技能源 ∪ 已有调度。
......
...@@ -575,7 +575,8 @@ def index(): ...@@ -575,7 +575,8 @@ def index():
"skill_enabled": row["skill_enabled"], "toggle_on": row["toggle_on"], "skill_enabled": row["skill_enabled"], "toggle_on": row["toggle_on"],
"provider": "", "fallback_provider": "", "provider": "", "fallback_provider": "",
}) })
sources.sort(key=lambda s: s["source"]) # 开启(开关 ON)的栏目置顶,组内保持字母序
sources.sort(key=lambda s: (0 if s.get("toggle_on") else 1, s["source"]))
# digest timer 状态 # digest timer 状态
digest_props = get_systemd_props("news-harvester-digest.timer") digest_props = get_systemd_props("news-harvester-digest.timer")
digest_run_time = (read_oncalendar_from_systemd("news-harvester-digest.timer") digest_run_time = (read_oncalendar_from_systemd("news-harvester-digest.timer")
...@@ -984,8 +985,9 @@ def _site_probe_source(site): ...@@ -984,8 +985,9 @@ def _site_probe_source(site):
@require_auth @require_auth
def login_list(): def login_list():
rows = pl.login_rows() rows = pl.login_rows()
src_rows = pl.login_source_rows()
test_state = dict(_login_test) test_state = dict(_login_test)
return render_template("logins.html", rows=rows, test_state=test_state) return render_template("logins.html", rows=rows, src_rows=src_rows, test_state=test_state)
@app.route("/login-save", methods=["POST"]) @app.route("/login-save", methods=["POST"])
...@@ -1080,6 +1082,35 @@ def login_test(site): ...@@ -1080,6 +1082,35 @@ def login_test(site):
return redirect(url_for("login_list")) return redirect(url_for("login_list"))
@app.route("/login-test-src/<source>", methods=["POST"])
@require_auth
def login_test_source(source):
"""按源测试登录态:对该源所属站点的共享实例执行登录预检(检查+未登录则自动登录)。"""
if not re.match(r"^[a-zA-Z0-9_-]+$", source):
abort(404)
if source not in {r["source"] for r in pl.login_source_rows()}:
abort(404)
with _login_test_lock:
if _login_test.get(source, {}).get("running"):
flash(f"源 {source} 登录测试已在运行", "error")
return redirect(url_for("login_list"))
_login_test[source] = {"running": True, "done": False, "start":
datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
def _run():
rc, out, err = call_platform("nh_login_preflight", source)
with _login_test_lock:
_login_test[source] = {
"running": False, "done": True, "rc": rc,
"output": ((out or "") + (("\n" + err) if err else ""))[-2000:],
"start": _login_test[source]["start"],
}
threading.Thread(target=_run, daemon=True).start()
flash(f"源 {source} 登录测试已启动(拉起其所属站点 {source.split('-')[0]} 的共享实例,约 10-60 秒,页面自动刷新显示结果)", "success")
return redirect(url_for("login_list"))
def call_platform(fn, *args): def call_platform(fn, *args):
"""供路由直接调用 platform.sh 函数的便捷包装。""" """供路由直接调用 platform.sh 函数的便捷包装。"""
return pl.call(fn, *args, timeout=180) return pl.call(fn, *args, timeout=180)
......
...@@ -62,7 +62,12 @@ ...@@ -62,7 +62,12 @@
</form> </form>
</td> </td>
<td> <td>
<a class="source-link" href="{{ url_for('source_detail', name=s.source) }}">{{ s.source }}</a> {% if s.timer_name == '—' %}
<span class="source-link" title="已安装但未建调度:打开左侧开关自动错峰创建调度">{{ s.source }}</span>
<span class="badge unknown" style="margin-left:4px">未排程</span>
{% else %}
<a class="source-link" href="{{ url_for('source_detail', name=s.source) }}">{{ s.source }}</a>
{% endif %}
</td> </td>
<td> <td>
{% if s.active == 'active' %} {% if s.active == 'active' %}
...@@ -115,8 +120,12 @@ ...@@ -115,8 +120,12 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<a class="btn btn-sm" href="{{ url_for('source_detail', name=s.source) }}">详情</a> {% if s.timer_name == '—' %}
<a class="btn btn-sm" href="{{ url_for('edit_source', name=s.source) }}">编辑</a> <span style="color:#888;font-size:12px" title="打开开关创建调度后可查看详情/编辑">未排程</span>
{% else %}
<a class="btn btn-sm" href="{{ url_for('source_detail', name=s.source) }}">详情</a>
<a class="btn btn-sm" href="{{ url_for('edit_source', name=s.source) }}">编辑</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
......
...@@ -9,43 +9,39 @@ ...@@ -9,43 +9,39 @@
<h1>站点登录</h1> <h1>站点登录</h1>
<p style="color:#666;font-size:13px"> <p style="color:#666;font-size:13px">
按<strong>站点</strong>维护凭据:站点 = 源名第一段,同一账号的多个频道共享一份凭据与配方 <strong>登录态按站点共享</strong>:同一站点的全部频道共用一个浏览器实例与一份登录
(如 <code>nikkei</code> 一份凭据覆盖 <code>nikkei-world / nikkei-markets / nikkei-tech</code> 全部频道)。 (<code>~/.news-harvester/profiles/&lt;站点&gt;/</code>,站点 = 源名第一段,
保存后以 600 权限落盘(<code>etc/news-harvester/logins/</code>),密码不再回显; 如 <code>nikkei-world / nikkei-markets / nikkei-tech</code> 共用 <code>nikkei</code> 一份登录,
每个频道采集前自动预检,未登录会自动登录。配方在技能仓 <code>logins/</code> 下维护(同样按站点)。 测任一频道即知全站状态)。<strong>凭据与配方同样按站点</strong>:一份账号密码+配方覆盖该站全部频道;
保存后以 600 权限落盘,密码不再回显。每个源采集前自动预检,未登录会自动登录。
</p> </p>
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
{% if messages %}{% for m in messages %}<div class="flash">{{ m }}</div>{% endfor %}{% endif %} {% if messages %}{% for m in messages %}<div class="flash">{{ m }}</div>{% endfor %}{% endif %}
{% endwith %} {% endwith %}
<h2>登录态 · 按源</h2>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>站点</th><th>登录账号</th><th>首页</th><th>覆盖频道</th><th>凭据</th><th>自动登录配方</th><th>上次测试</th><th>操作</th></tr> <tr><th>源</th><th>站点</th><th>采集开关</th><th>登录账号(站点级)</th><th>自动登录配方</th><th>站点实例</th><th>上次测试</th><th>操作</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for r in rows %} {% for r in src_rows %}
<tr> <tr>
<td><code>{{ r.source }}</code>{% if r.display and r.display != r.source %} <span style="color:#888">{{ r.display }}</span>{% endif %}</td>
<td><code>{{ r.site }}</code></td> <td><code>{{ r.site }}</code></td>
<td>{% if r.enabled %}<span class="badge ok">开</span>{% else %}<span class="badge unknown">停</span>{% endif %}</td>
<td>{{ r.user or '—' }}</td> <td>{{ r.user or '—' }}</td>
<td>{{ r.url or '—' }}</td>
<td> <td>
{% if r.sources %} {% if r.has_recipe %}<span class="badge ok">支持</span>
{% for s in r.sources %}<code style="margin-right:4px">{{ s }}</code>{% endfor %} {% else %}<span class="badge unknown">无配方</span>{% endif %}
{% else %}
<span class="badge unknown">未接入源(配方与凭据先行,装源后直接生效)</span>
{% endif %}
</td>
<td>
{% if r.configured %}<span class="badge ok">已配置</span>
{% else %}<span class="badge unknown">未配置</span>{% endif %}
</td> </td>
<td> <td>
{% if r.has_recipe %}<span class="badge ok">支持</span> {% if r.has_profile %}<span class="badge ok">已建</span>
{% else %}<span class="badge unknown">无配方(配置凭据后点右侧按钮自动生成)</span>{% endif %} {% else %}<span class="badge unknown">未建(测试/首跑时自动创建)</span>{% endif %}
</td> </td>
{% set st = test_state.get(r.site) %} {% set st = test_state.get(r.source) %}
<td> <td>
{% if st and st.running %} {% if st and st.running %}
<span class="badge active">测试中…(启动于 {{ st.start }})</span> <span class="badge active">测试中…(启动于 {{ st.start }})</span>
...@@ -60,15 +56,56 @@ ...@@ -60,15 +56,56 @@
— —
{% endif %} {% endif %}
</td> </td>
<td>
<form method="POST" action="{{ url_for('login_test_source', source=r.source) }}" style="display:inline">
<button type="submit" class="btn btn-sm" {% if st and st.running %}disabled{% endif %}>测试登录</button>
</form>
{% if not r.configured %}
<span style="color:#f0a050;font-size:12px">站点未配凭据</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2>站点凭据 · 按站点</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>站点</th><th>登录账号</th><th>首页</th><th>覆盖频道</th><th>凭据</th><th>配方</th><th>操作</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td><code>{{ r.site }}</code></td>
<td>{{ r.user or '—' }}</td>
<td>{{ r.url or '—' }}</td>
<td>
{% if r.sources %}
{% for s in r.sources %}<code style="margin-right:4px">{{ s }}</code>{% endfor %}
{% else %}
<span class="badge unknown">未接入源(凭据与配方先行,装源后直接生效)</span>
{% endif %}
</td>
<td>
{% if r.configured %}<span class="badge ok">已配置</span>
{% else %}<span class="badge unknown">未配置</span>{% endif %}
</td>
<td>
{% if r.has_recipe %}<span class="badge ok">支持</span>
{% else %}<span class="badge unknown">无配方(配置凭据后点右侧按钮自动生成)</span>{% endif %}
</td>
<td> <td>
{% if r.has_recipe %} {% if r.has_recipe %}
<form method="POST" action="{{ url_for('login_test', site=r.site) }}" style="display:inline"> <form method="POST" action="{{ url_for('login_test', site=r.site) }}" style="display:inline">
<button type="submit" class="btn btn-sm" {% if st and st.running %}disabled{% endif %}>测试登录</button> <button type="submit" class="btn btn-sm" {% if not r.sources %}title="该站点无接入源,以凭据首页为探测入口"{% else %}title="多频道站点建议用上表按源分别测试"{% endif %}>测试登录(站点级)</button>
</form> </form>
{% elif r.configured %} {% elif r.configured %}
<form method="POST" action="{{ url_for('login_discover', site=r.site) }}" style="display:inline" <form method="POST" action="{{ url_for('login_discover', site=r.site) }}" style="display:inline"
onsubmit="return confirm('自动识别 {{ r.site }} 的登录流程并生成站点级配方,随后立即用凭据实登验证?')"> onsubmit="return confirm('自动识别 {{ r.site }} 的登录流程并生成站点级配方,随后立即用凭据实登验证?')">
<button type="submit" class="btn btn-sm btn-primary" {% if st and st.running %}disabled{% endif %}>自动配置配方并测试</button> <button type="submit" class="btn btn-sm btn-primary">自动配置配方并测试</button>
</form> </form>
{% endif %} {% endif %}
<button class="btn btn-sm" onclick="fillForm('{{ r.site }}', '{{ r.url }}', '{{ r.user }}')">编辑</button> <button class="btn btn-sm" onclick="fillForm('{{ r.site }}', '{{ r.url }}', '{{ r.user }}')">编辑</button>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment