/** * 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 })), ))) }, } }, })