import { FormEvent, useEffect, useMemo, useState } from 'react' import { Loader2, Pencil, Plus, Trash2, UserCog } from 'lucide-react' import toast from 'react-hot-toast' import { libraryAPI } from '../api/library' import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles' import { useAuthStore } from '../stores/auth' import { usePlayProfileStore } from '../stores/playProfile' import { confirmAction } from '../components/ConfirmDialog' import { requestPassword } from '../components/PasswordDialog' import { requestPIN } from '../components/PinDialog' import type { Library, PlayProfile } from '../types' const MAX_PLAY_PROFILES = 3 // ProfileManagementPage replicates the Vue ProfileManagementView. It // lets each user define private "viewing personas" with // different content-rating gates, library access, and player defaults. // // All persistence is real: data is written to /api/play-profiles which // is backed by the Go PlayProfileService. export function ProfileManagementPage() { const userID = useAuthStore((s) => s.user?.id ?? '') const activeProfileId = usePlayProfileStore((s) => s.activeProfileId) const setActiveProfile = usePlayProfileStore((s) => s.setActiveProfile) const [profiles, setProfiles] = useState([]) const [libraries, setLibraries] = useState([]) const [loading, setLoading] = useState(true) const [editing, setEditing] = useState(null) const [showForm, setShowForm] = useState(false) const refresh = async () => { setLoading(true) try { const [p, l] = await Promise.all([ playProfilesAPI.list(), libraryAPI.list().catch(() => [] as Library[]), ]) setProfiles(p) setLibraries(l) } finally { setLoading(false) } } useEffect(() => { refresh().catch(() => undefined) }, []) const onDelete = async (p: PlayProfile) => { if (!(await confirmAction({ title: '删除播放档案', message: `确定删除 Profile「${p.name}」? 删除前需要再次验证。`, confirmText: '继续删除' }))) return try { const proof: { pin?: string; password?: string } = {} if (p.require_pin) { const pin = await requestPIN({ title: '删除 Profile 需要 PIN', message: `请输入「${p.name}」的 PIN;也可以取消后改用账号密码删除。`, profileName: p.name, }) if (!pin) return proof.pin = pin } else { const password = await requestPassword({ title: '删除 Profile 需要密码', message: `请输入当前账号密码以删除「${p.name}」。`, confirmText: '删除', }) if (!password) return proof.password = password } await playProfilesAPI.remove(p.id, proof) toast.success('已删除') await refresh() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败' toast.error(msg) } } const openCreate = () => { if (profiles.length >= MAX_PLAY_PROFILES) { toast.error(`每个用户最多只能创建 ${MAX_PLAY_PROFILES} 个观影 Profile`) return } setEditing(null) setShowForm(true) } const openEdit = (p: PlayProfile) => { setEditing(p) setShowForm(true) } const selectProfile = async (profile: PlayProfile) => { if (profile.user_id !== userID) { toast.error('只能切换当前账号自己的 Profile') return } try { let pinToken: string | null = null if (profile.require_pin) { const pin = await requestPIN({ profileName: profile.name }) if (!pin) return const verified = await playProfilesAPI.verifyPin(profile.id, pin) pinToken = verified.token } setActiveProfile(profile.id, pinToken) toast.success(`已切换到「${profile.name}」`) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'PIN 验证失败' toast.error(msg) } } return (

观影 Profile

为不同场景(儿童 / 影院 / 成人)定义独立的内容分级和媒体库访问规则

当前账号已创建 {profiles.length}/{MAX_PLAY_PROFILES} 个 Profile。Profile 仅当前用户可见,不会与其他用户共享。
{loading && (
)} {!loading && profiles.length === 0 && (
👤

暂无 Profile

点击右上角"创建 Profile"开始

)} {!loading && profiles.length > 0 && (
{profiles.map((p) => ( selectProfile(p)} onEdit={() => openEdit(p)} onDelete={() => onDelete(p)} /> ))}
)} {showForm && ( setShowForm(false)} onSaved={async () => { setShowForm(false) await refresh() }} /> )}
) } function ProfileCard({ profile, libraries, active, onSelect, onEdit, onDelete, }: { profile: PlayProfile libraries: Library[] active: boolean onSelect: () => void onEdit: () => void onDelete: () => void }) { const libNames = useMemo(() => { if (!profile.allowed_library_ids?.length) return '全部' const idx = new Map(libraries.map((l) => [l.id, l.name])) return profile.allowed_library_ids.map((id) => idx.get(id) ?? id).join(', ') }, [profile, libraries]) return (
{profile.name[0]?.toUpperCase()}
{profile.name} {profile.is_default && ( 默认 )} {active && ( 当前使用 )} {profile.allow_adult && ( 成人内容 )} {profile.require_pin && ( 🔒 PIN )}
{profile.content_rating_limit && 分级: {profile.content_rating_limit}} 媒体库: {libNames}
观看时长 {Math.round(profile.total_watch_time / 3600)} 小时 {profile.last_active_at && ` · 最近活跃 ${new Date(profile.last_active_at).toLocaleDateString()}`}
) } function ProfileFormModal({ editing, libraries, defaultUserID, onClose, onSaved, }: { editing: PlayProfile | null libraries: Library[] defaultUserID: string onClose: () => void onSaved: () => void | Promise }) { const [form, setForm] = useState(() => ({ user_id: editing?.user_id ?? defaultUserID, name: editing?.name ?? '', is_default: editing?.is_default ?? false, content_rating_limit: editing?.content_rating_limit ?? '', allow_adult: editing?.allow_adult ?? false, require_pin: editing?.require_pin ?? false, pin: '', preferred_subtitle_lang: editing?.preferred_subtitle_lang ?? '', preferred_audio_lang: editing?.preferred_audio_lang ?? '', autoplay_next: editing?.autoplay_next ?? true, skip_intro: editing?.skip_intro ?? false, allowed_library_ids: editing?.allowed_library_ids ?? [], })) const [saving, setSaving] = useState(false) const update = (patch: Partial) => setForm((f) => ({ ...f, ...patch })) const toggleLib = (id: string) => { const next = form.allowed_library_ids.includes(id) ? form.allowed_library_ids.filter((x) => x !== id) : [...form.allowed_library_ids, id] update({ allowed_library_ids: next }) } const onSubmit = async (e: FormEvent) => { e.preventDefault() setSaving(true) try { if (editing) { await playProfilesAPI.update(editing.id, form) } else { await playProfilesAPI.create(form) } toast.success('已保存') await onSaved() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败' toast.error(msg) } finally { setSaving(false) } } return (

{editing ? '编辑 Profile' : '创建 Profile'}

update({ name: e.target.value })} /> update({ is_default: v })} /> update({ allow_adult: v })} /> update({ require_pin: v })} /> {form.require_pin && ( update({ pin: e.target.value })} /> )}
update({ autoplay_next: v })} /> update({ skip_intro: v })} />
{libraries.map((l) => ( ))} {libraries.length === 0 && ( 暂无媒体库 )}
) } function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( ) } function Toggle({ label, hint, checked, onChange, }: { label: string hint?: string checked: boolean onChange: (v: boolean) => void }) { return ( ) }