- 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
457 lines
16 KiB
JavaScript
457 lines
16 KiB
JavaScript
/**
|
||
* 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 })),
|
||
)))
|
||
},
|
||
}
|
||
},
|
||
})
|