feat: QR login, AcFun settings panel, and 动态 (moment) tools

- scan.acfun.cn QR flow (start -> long-poll scanResult -> acceptResult) with
  rotated-signature handling, background poller, durable session in the Host
  credential service, and cookie handoff to the provider
- Client half: Settings -> AcFun page (status, QR code, poll, logout) over a
  same-origin /acfun-panel JSON API with cross-origin rejection
- acfun_moment / acfun_moment_comments / acfun_topic read tools, moment_like /
  moment_unlike / moment_banana actions, and comment posting on moments
- acfun_account status tool; docs for the QR contract, the 广场/动态 research
  verdict, and the client-composition restart requirement
This commit is contained in:
tamakyi
2026-10-27 11:50:44 +08:00
parent f9bedde19e
commit 97825f020d
19 changed files with 3723 additions and 189 deletions
+456
View File
@@ -0,0 +1,456 @@
/**
* AcFun settings panel (Browser half).
*
* Static Client plugin: this bundle is served under `/plugins` from the package
* root and registers a `settings.section` page that talks to the Host half over
* the same-origin `/acfun-panel` routes (`lib/panel.js`).
*
* Plain JavaScript, no JSX, no imports. React comes from the browser module
* table; the only other symbols are the closure parameters provided by the
* module loader.
*/
window.__ModuleLoader__.load({
id: 'dsh-plugin-acfun',
factory(require) {
const React = require('react')
const h = React.createElement
const API = '/acfun-panel'
const TEXT = {
zh: {
sectionTitle: 'AcFun',
sectionHint: '登录 A 站账号后,互动工具(点赞 / 投蕉 / 收藏 / 关注 / 评论)才能使用。',
status: '登录状态',
loggedIn: '已登录(Cookie 已配置)',
loggedOut: '未登录(匿名读取)',
source: '凭据来源',
sourceConfig: '插件配置',
sourceEnv: '环境变量',
sourceQr: '扫码登录',
sourceNone: '未配置',
writes: '互动工具',
writesOn: '已开启',
writesOff: '已关闭(allowWriteActions: false)',
loginSection: '扫码登录',
loginHint: '用 AcFun App 扫描二维码。二维码由 A 站生成,只在服务端轮询状态,不经过第三方。',
startLogin: '生成二维码',
refreshQr: '刷新二维码',
cancelLogin: '取消',
logout: '退出登录',
waiting: '等待扫码…',
scanned: '已扫码,请在手机上确认',
confirmed: '登录成功,Cookie 已保存',
expired: '二维码已过期,请刷新',
error: '登录失败',
idle: '尚未开始',
loading: '加载中…',
retry: '重试',
tools: '可用工具',
qrAlt: 'AcFun 登录二维码',
unavailable: '当前 Host 未启用扫码登录接口。',
cookieHint: '也可以把浏览器 Cookie 填入插件配置的 cookie / ACFUN_COOKIE。',
},
en: {
sectionTitle: 'AcFun',
sectionHint: 'Sign in to AcFun to enable interaction tools (like / banana / favorite / follow / comment).',
status: 'Account',
loggedIn: 'Signed in (cookie configured)',
loggedOut: 'Anonymous (reads only)',
source: 'Credential source',
sourceConfig: 'Plugin config',
sourceEnv: 'Environment variable',
sourceQr: 'QR login',
sourceNone: 'Not configured',
writes: 'Interaction tools',
writesOn: 'Enabled',
writesOff: 'Disabled (allowWriteActions: false)',
loginSection: 'QR login',
loginHint: 'Scan the code with the AcFun app. The code is issued by AcFun; only the login state is polled by the Host.',
startLogin: 'Show QR code',
refreshQr: 'Refresh QR code',
cancelLogin: 'Cancel',
logout: 'Sign out',
waiting: 'Waiting for scan…',
scanned: 'Scanned — confirm on your phone',
confirmed: 'Signed in; cookie saved',
expired: 'QR code expired — refresh it',
error: 'Login failed',
idle: 'Not started',
loading: 'Loading…',
retry: 'Retry',
tools: 'Available tools',
qrAlt: 'AcFun login QR code',
unavailable: 'QR login is not available in this Host.',
cookieHint: 'You can also paste browser cookies into the plugin config cookie / ACFUN_COOKIE.',
},
}
/** Pick a dictionary from the live locale snapshot. */
function pickText(ctx) {
let active = 'en'
try {
active = ctx.locale.getLocale().active || 'en'
} catch {
active = 'en'
}
return String(active).toLowerCase().startsWith('zh') ? TEXT.zh : TEXT.en
}
/** Subscribe one component to locale changes. */
function useLocale(ctx) {
const [dict, setDict] = React.useState(() => pickText(ctx))
React.useEffect(() => {
let dispose
try {
dispose = ctx.locale.subscribe(() => setDict(pickText(ctx)))
} catch {
dispose = undefined
}
return () => {
if (typeof dispose === 'function') dispose()
}
}, [])
return dict
}
/** Call one panel route. */
async function call(path, options) {
const response = await fetch(API + path, {
method: options && options.method ? options.method : 'GET',
headers: { Accept: 'application/json' },
body: options && options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
const text = await response.text()
let payload
try {
payload = text ? JSON.parse(text) : {}
} catch {
throw new Error('AcFun panel returned malformed JSON (HTTP ' + response.status + ').')
}
if (!response.ok || payload.ok === false) {
const message = payload && payload.error && payload.error.message ? payload.error.message : 'HTTP ' + response.status
throw new Error(message)
}
return payload
}
const COLORS = {
bgLayer1: 'var(--dsw-alias-bg-layer-1)',
bgLayer2: 'var(--dsw-alias-bg-layer-2)',
border: 'var(--dsw-alias-border-l1)',
borderStrong: 'var(--dsw-alias-border-l2)',
primary: 'var(--dsw-alias-label-primary)',
secondary: 'var(--dsw-alias-label-secondary)',
accent: 'var(--dsw-alias-brand-primary)',
success: 'var(--dsw-alias-state-success-primary)',
error: 'var(--dsw-alias-state-error-primary)',
warn: 'var(--dsw-alias-state-warn-primary)',
idle: 'var(--dsw-alias-state-idle-primary)',
}
const styles = {
page: { display: 'flex', flexDirection: 'column', gap: '18px', color: COLORS.primary, maxWidth: '760px' },
header: { display: 'flex', flexDirection: 'column', gap: '6px' },
title: { margin: 0, fontSize: '18px', fontWeight: 600 },
hint: { margin: 0, color: COLORS.secondary, fontSize: '13px', lineHeight: '20px' },
card: {
border: '1px solid ' + COLORS.border,
background: COLORS.bgLayer1,
borderRadius: '12px',
padding: '16px 18px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
},
cardTitle: { fontSize: '14px', fontWeight: 600 },
row: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px', fontSize: '13px' },
label: { color: COLORS.secondary },
value: { color: COLORS.primary },
badge: { borderRadius: '999px', padding: '2px 10px', fontSize: '12px', border: '1px solid currentColor' },
buttons: { display: 'flex', gap: '10px', flexWrap: 'wrap' },
button: {
appearance: 'none',
border: '1px solid transparent',
borderRadius: '8px',
padding: '7px 14px',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
background: COLORS.accent,
color: '#fff',
},
buttonGhost: {
appearance: 'none',
borderRadius: '8px',
padding: '7px 14px',
fontSize: '13px',
cursor: 'pointer',
background: 'transparent',
color: COLORS.primary,
border: '1px solid ' + COLORS.borderStrong,
},
qrBox: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '10px',
padding: '14px',
background: '#fff',
borderRadius: '12px',
alignSelf: 'flex-start',
},
qrImage: { width: '220px', height: '220px', display: 'block' },
qrFallback: {
width: '220px',
minHeight: '120px',
wordBreak: 'break-all',
color: '#333',
fontSize: '12px',
fontFamily: 'monospace',
},
stateLine: { display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px' },
tools: { display: 'flex', flexDirection: 'column', gap: '6px', margin: 0, padding: 0, listStyle: 'none' },
toolName: { fontFamily: 'monospace', fontSize: '12px', color: COLORS.accent },
toolSummary: { color: COLORS.secondary, fontSize: '12px', lineHeight: '18px' },
error: { color: COLORS.error, fontSize: '13px', margin: 0 },
footer: { color: COLORS.secondary, fontSize: '12px', lineHeight: '18px', margin: 0 },
}
const STATE_LABEL = {
idle: 'idle',
waiting: 'waiting',
scanned: 'scanned',
confirmed: 'confirmed',
expired: 'expired',
error: 'error',
}
/** Small label + value row. */
function Row(props) {
return h('div', { style: styles.row }, h('span', { style: styles.label }, props.label), props.children)
}
/** One badge whose color follows a state. */
function Badge(props) {
const color = props.tone === 'ok' ? COLORS.success : props.tone === 'warn' ? COLORS.warn : props.tone === 'error' ? COLORS.error : COLORS.idle
return h('span', { style: { ...styles.badge, color, whiteSpace: 'nowrap' } }, props.children)
}
/** The settings page component. */
function AcfunPanel(props) {
const ctx = props.ctx
const t = useLocale(ctx)
const [state, setState] = React.useState({ status: 'loading', data: null, error: '' })
const [login, setLogin] = React.useState(null)
const [busy, setBusy] = React.useState(false)
const [actionError, setActionError] = React.useState('')
const loadState = React.useCallback(async () => {
try {
const payload = await call('/state')
setState({ status: 'ready', data: payload, error: '' })
} catch (error) {
setState({ status: 'error', data: null, error: String((error && error.message) || error) })
}
}, [])
React.useEffect(() => {
loadState()
}, [])
// Poll while a QR ticket is waiting for the phone.
React.useEffect(() => {
if (!login || !login.ticket) return undefined
if (login.state !== 'waiting' && login.state !== 'scanned') return undefined
const timer = window.setInterval(async () => {
try {
const payload = await call('/login/poll?ticket=' + encodeURIComponent(login.ticket))
setLogin(payload.login || null)
if (payload.login && (payload.login.state === 'confirmed' || payload.login.state === 'expired' || payload.login.state === 'error')) {
window.clearInterval(timer)
if (payload.login.state === 'confirmed') await loadState()
}
} catch (error) {
setActionError(String((error && error.message) || error))
window.clearInterval(timer)
}
}, 2000)
return () => window.clearInterval(timer)
}, [login && login.ticket, login && login.state])
const startLogin = React.useCallback(async () => {
setBusy(true)
setActionError('')
try {
const payload = await call('/login/start', { method: 'POST', body: {} })
setLogin(payload.login || null)
} catch (error) {
setActionError(String((error && error.message) || error))
} finally {
setBusy(false)
}
}, [])
const cancelLogin = React.useCallback(async () => {
setBusy(true)
setActionError('')
try {
const payload = await call('/login/cancel', { method: 'POST', body: {} })
setLogin(payload.login || null)
} catch (error) {
setActionError(String((error && error.message) || error))
} finally {
setBusy(false)
}
}, [])
const logout = React.useCallback(async () => {
setBusy(true)
setActionError('')
try {
await call('/logout', { method: 'POST', body: {} })
setLogin(null)
await loadState()
} catch (error) {
setActionError(String((error && error.message) || error))
} finally {
setBusy(false)
}
}, [loadState])
const data = state.data
const auth = data ? data.auth : null
const sourceLabel = auth
? auth.source === 'config'
? t.sourceConfig
: auth.source === 'env'
? t.sourceEnv
: auth.source === 'qr'
? t.sourceQr
: t.sourceNone
: ''
const loginState = login && login.state ? login.state : 'idle'
const stateText =
loginState === 'waiting'
? t.waiting
: loginState === 'scanned'
? t.scanned
: loginState === 'confirmed'
? t.confirmed
: loginState === 'expired'
? t.expired
: loginState === 'error'
? t.error
: t.idle
const qr = login && login.qr ? login.qr : null
return h(
'div',
{ style: styles.page },
h(
'div',
{ style: styles.header },
h('h2', { style: styles.title }, t.sectionTitle),
h('p', { style: styles.hint }, t.sectionHint),
),
state.status === 'loading' ? h('p', { style: styles.hint }, t.loading) : null,
state.status === 'error'
? h(
'div',
{ style: styles.card },
h('p', { style: styles.error }, state.error),
h('div', { style: styles.buttons }, h('button', { style: styles.buttonGhost, onClick: loadState }, t.retry)),
)
: null,
auth
? h(
'div',
{ style: styles.card },
h(
Row,
{ label: t.status },
h(Badge, { tone: auth.configured ? 'ok' : 'idle' }, auth.configured ? t.loggedIn : t.loggedOut),
),
h(Row, { label: t.source }, h('span', { style: styles.value }, sourceLabel)),
h(
Row,
{ label: t.writes },
h(Badge, { tone: auth.writesEnabled ? 'ok' : 'warn' }, auth.writesEnabled ? t.writesOn : t.writesOff),
),
h('p', { style: styles.footer }, t.cookieHint),
)
: null,
h(
'div',
{ style: styles.card },
h('div', { style: styles.cardTitle }, t.loginSection),
h('p', { style: styles.hint }, t.loginHint),
login && login.available === false ? h('p', { style: styles.hint }, t.unavailable) : null,
qr
? h(
'div',
{ style: styles.qrBox },
qr.kind === 'image-url' || qr.kind === 'data-url'
? h('img', { src: qr.value, alt: t.qrAlt, style: styles.qrImage })
: h('div', { style: styles.qrFallback }, qr.value),
)
: null,
h(
'div',
{ style: styles.stateLine },
h('span', { style: styles.label }, stateText),
login && login.message ? h('span', { style: styles.value }, login.message) : null,
),
h(
'div',
{ style: styles.buttons },
h(
'button',
{ style: styles.button, disabled: busy, onClick: startLogin },
login && login.ticket ? t.refreshQr : t.startLogin,
),
login && login.ticket
? h('button', { style: styles.buttonGhost, disabled: busy, onClick: cancelLogin }, t.cancelLogin)
: null,
auth && auth.configured && auth.source === 'qr'
? h('button', { style: styles.buttonGhost, disabled: busy, onClick: logout }, t.logout)
: null,
),
actionError ? h('p', { style: styles.error }, actionError) : null,
),
data && data.tools && data.tools.length > 0
? h(
'div',
{ style: styles.card },
h('div', { style: styles.cardTitle }, t.tools),
h(
'ul',
{ style: styles.tools },
data.tools.map((tool) =>
h(
'li',
{ key: tool.name },
h('div', { style: styles.toolName }, tool.name),
h('div', { style: styles.toolSummary }, tool.summary),
),
),
),
)
: null,
)
}
return {
inject: ['slots', 'locale'],
apply(ctx) {
ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register(
{ name: 'settings.section', id: 'acfun', order: 16, label: 'AcFun' },
(props) => h(AcfunPanel, Object.assign({}, props, { ctx })),
)))
},
}
},
})