Files
dsh-acfun/client.js
T
tamakyi 97825f020d 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
2026-10-27 11:50:44 +08:00

457 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 })),
)))
},
}
},
})