|
|
@@ -0,0 +1,279 @@
|
|
|
+import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
|
|
+import { Link, Navigate, useParams } from 'react-router-dom'
|
|
|
+import QRCode from 'qrcode'
|
|
|
+import { api } from '../api'
|
|
|
+import { useAuth } from '../AuthContext'
|
|
|
+import { useWorkspace } from '../WorkspaceContext'
|
|
|
+import { StatusBadge } from '../components/StatusBadge'
|
|
|
+import { AdminCourseDetail } from './admin/AdminCourseDetail'
|
|
|
+import { formatDateTime } from '../utils/date'
|
|
|
+import type { CourseStatus } from '../types'
|
|
|
+
|
|
|
+type CourseSortField = 'updated_at' | 'created_at'
|
|
|
+type SortOrder = 'desc' | 'asc'
|
|
|
+
|
|
|
+export function OrganizationManage() {
|
|
|
+ const { slug = '', id: courseId } = useParams()
|
|
|
+ const { user } = useAuth()
|
|
|
+ const { organizations, selectWorkspace } = useWorkspace()
|
|
|
+ const organization = organizations.find((item) => item.slug === slug)
|
|
|
+ const [content, setContent] = useState<any>(null)
|
|
|
+ const [manage, setManage] = useState<any>(null)
|
|
|
+ const [error, setError] = useState('')
|
|
|
+ const [message, setMessage] = useState('')
|
|
|
+ const [invite, setInvite] = useState('')
|
|
|
+ const [inviteQrCode, setInviteQrCode] = useState('')
|
|
|
+ const [inviteCopied, setInviteCopied] = useState(false)
|
|
|
+ const [createdLogin, setCreatedLogin] = useState<{ phone: string; password: string } | null>(null)
|
|
|
+ const [editingMemberId, setEditingMemberId] = useState<string | null>(null)
|
|
|
+ const [editingMemberName, setEditingMemberName] = useState('')
|
|
|
+ const [savingMemberName, setSavingMemberName] = useState(false)
|
|
|
+ const [showAudit, setShowAudit] = useState(false)
|
|
|
+ const [courseSortField, setCourseSortField] = useState<CourseSortField>('updated_at')
|
|
|
+ const [courseSortOrder, setCourseSortOrder] = useState<SortOrder>('desc')
|
|
|
+
|
|
|
+ async function load() {
|
|
|
+ if (!organization) return
|
|
|
+ try {
|
|
|
+ const [c, m] = await Promise.all([api.organizations.content(organization.id), api.organizations.manage(organization.id)])
|
|
|
+ setContent(c); setManage(m)
|
|
|
+ } catch (e) { setError((e as Error).message) }
|
|
|
+ }
|
|
|
+ useEffect(() => { load() }, [organization?.id])
|
|
|
+ const activeInvite = invite || (manage?.inviteToken ? `${window.location.origin}/join/${manage.inviteToken}` : '')
|
|
|
+ useEffect(() => {
|
|
|
+ if (!activeInvite) { setInviteQrCode(''); return }
|
|
|
+ QRCode.toDataURL(activeInvite, { width: 176, margin: 1, errorCorrectionLevel: 'M' })
|
|
|
+ .then(setInviteQrCode)
|
|
|
+ .catch(() => setInviteQrCode(''))
|
|
|
+ }, [activeInvite])
|
|
|
+ const sortedCourses = useMemo(() => {
|
|
|
+ if (!content?.courses) return []
|
|
|
+ return [...content.courses].sort((a: any, b: any) => {
|
|
|
+ const valA = new Date(courseSortField === 'updated_at' ? (a.updated_at || a.created_at) : a.created_at).getTime() || 0
|
|
|
+ const valB = new Date(courseSortField === 'updated_at' ? (b.updated_at || b.created_at) : b.created_at).getTime() || 0
|
|
|
+ return courseSortOrder === 'desc' ? valB - valA : valA - valB
|
|
|
+ })
|
|
|
+ }, [content?.courses, courseSortField, courseSortOrder])
|
|
|
+
|
|
|
+ if (!user) return <Navigate to="/" replace />
|
|
|
+ if (!organization || organization.role !== 'MANAGER') return <div className="page">没有组织管理权限</div>
|
|
|
+ if (courseId) return <div className="org-manage-shell"><header className="admin-topbar"><Link to={`/organizations/${slug}/manage`}>← {organization.name} · 组织管理</Link></header>
|
|
|
+ <AdminCourseDetail backTo={`/organizations/${slug}/manage`} /></div>
|
|
|
+
|
|
|
+ async function createUser(e: FormEvent<HTMLFormElement>) {
|
|
|
+ e.preventDefault()
|
|
|
+ const form = new FormData(e.currentTarget)
|
|
|
+ try {
|
|
|
+ const result = await api.organizations.createUser(organization!.id, String(form.get('phone')), String(form.get('contactName')))
|
|
|
+ setMessage(`已创建 ${result.user.contactName}:手机号 ${result.user.phone},初始密码 ${result.initialPassword}`)
|
|
|
+ setCreatedLogin({ phone: result.user.phone, password: result.initialPassword })
|
|
|
+ ;(e.currentTarget as HTMLFormElement).reset()
|
|
|
+ await load()
|
|
|
+ } catch (x) { setError((x as Error).message) }
|
|
|
+ }
|
|
|
+ async function review(id: string, approve: boolean) {
|
|
|
+ try { await api.organizations.review(organization!.id, id, approve); await load() }
|
|
|
+ catch (x) { setError((x as Error).message) }
|
|
|
+ }
|
|
|
+ async function rotateInvite() {
|
|
|
+ if (manage?.inviteActive && !confirm('生成新链接会立即停用旧链接,继续吗?')) return
|
|
|
+ try {
|
|
|
+ const result = await api.organizations.rotateInvite(organization!.id)
|
|
|
+ setInvite(`${window.location.origin}/join/${result.token}`)
|
|
|
+ await load()
|
|
|
+ } catch (x) { setError((x as Error).message) }
|
|
|
+ }
|
|
|
+ async function copyInvite() {
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(activeInvite)
|
|
|
+ setInviteCopied(true)
|
|
|
+ window.setTimeout(() => setInviteCopied(false), 1800)
|
|
|
+ } catch { setError('复制失败,请手动复制链接') }
|
|
|
+ }
|
|
|
+ async function saveMemberName(memberId: string) {
|
|
|
+ const contactName = editingMemberName.trim()
|
|
|
+ if (!contactName) { setError('请填写姓名'); return }
|
|
|
+ setSavingMemberName(true)
|
|
|
+ setError('')
|
|
|
+ try {
|
|
|
+ await api.organizations.updateMemberName(organization!.id, memberId, contactName)
|
|
|
+ setEditingMemberId(null)
|
|
|
+ setMessage('姓名已更新')
|
|
|
+ await load()
|
|
|
+ } catch (x) { setError((x as Error).message) } finally { setSavingMemberName(false) }
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleCourseSort(field: CourseSortField) {
|
|
|
+ if (courseSortField === field) {
|
|
|
+ setCourseSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'))
|
|
|
+ } else {
|
|
|
+ setCourseSortField(field)
|
|
|
+ setCourseSortOrder('desc')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return <div className="org-manage-shell">
|
|
|
+ <header className="admin-topbar org-manage-topbar">
|
|
|
+ <Link className="org-manage-back" to={`/organizations/${slug}`} aria-label="返回机构主页">← 返回机构主页</Link>
|
|
|
+ </header>
|
|
|
+ <main className="page admin-page">
|
|
|
+ <div className="admin-header-row"><div><h1>{organization.name} · 机构设置</h1><p>成员、课程与组织账单</p></div></div>
|
|
|
+ {error && <div className="notice notice-error">{error}</div>}
|
|
|
+ {message && <div className="notice notice-success">{message}</div>}
|
|
|
+ {createdLogin && <div className="org-created-login"><span>请将登录信息交给新老师,并提醒首次登录修改密码。</span>
|
|
|
+ <button className="secondary-button small-button" onClick={() => navigator.clipboard.writeText(`手机号:${createdLogin.phone}\n初始密码:${createdLogin.password}`)}>复制登录信息</button></div>}
|
|
|
+ {!content || !manage ? <div className="center-loading">正在加载组织数据…</div> : <>
|
|
|
+ {!organization.isActive && <div className="notice notice-error">该组织已停用:可查看和处理历史课程,暂不能新增成员或课程。</div>}
|
|
|
+ <div className="stats-grid"><div className="stat-card"><div className="stat-content"><small>组织成员</small><strong>{manage.members.length}</strong></div></div>
|
|
|
+ <div className="stat-card"><div className="stat-content"><small>组织课程</small><strong>{content.courses.length}</strong></div></div>
|
|
|
+ <div className="stat-card"><div className="stat-content"><small>组织积分余额</small><strong>{content.organization.pointsBalance}</strong></div></div></div>
|
|
|
+ <section className="admin-section-block"><h2>现场创建老师</h2><form onSubmit={createUser} className="org-inline-form">
|
|
|
+ <label>手机号<input name="phone" required pattern="1[0-9]{10}" inputMode="numeric" /></label>
|
|
|
+ <label>姓名<input name="contactName" required maxLength={80} /></label>
|
|
|
+ <button className="primary-button" disabled={!organization.isActive}>创建账号(初始密码 888888)</button>
|
|
|
+ </form></section>
|
|
|
+ <section className="admin-section-block"><h2>免审批注册链接</h2><p>持有链接的人注册或登录后,可直接加入本组织。</p>
|
|
|
+ <div className="org-invite-actions"><button className="secondary-button" onClick={rotateInvite} disabled={!organization.isActive}>{manage.inviteActive ? '重新生成并停用旧链接' : '生成注册链接'}</button>
|
|
|
+ {manage.inviteActive && <button className="secondary-button" onClick={async () => {
|
|
|
+ if (!confirm('停用后,已发出的注册链接将无法再加入组织。')) return
|
|
|
+ await api.organizations.disableInvite(organization.id); setInvite(''); await load()
|
|
|
+ }}>停用当前链接</button>}</div>
|
|
|
+ {activeInvite && <div className="org-invite-display">
|
|
|
+ <div className="org-invite-link"><label htmlFor="organization-invite-link">当前注册链接</label><div><input id="organization-invite-link" readOnly value={activeInvite} /><button className="secondary-button small-button" onClick={copyInvite}>{inviteCopied ? '已复制' : '复制链接'}</button></div></div>
|
|
|
+ {inviteQrCode && <div className="org-invite-qr"><img src={inviteQrCode} alt="注册链接二维码" /><span>扫码打开注册链接</span></div>}
|
|
|
+ </div>}
|
|
|
+ {manage.inviteActive && !activeInvite && <p className="org-invite-legacy-note">当前有效链接生成于此前版本,出于安全原因无法还原。请重新生成链接后,即可展示并复制二维码。</p>}
|
|
|
+ </section>
|
|
|
+ <section className="admin-section-block"><h2>待审批申请</h2>
|
|
|
+ {manage.applications.filter((a: any) => a.status === 'PENDING').length === 0 ? <p>暂无待审批申请</p> :
|
|
|
+ manage.applications.filter((a: any) => a.status === 'PENDING').map((a: any) => <div className="org-list-row" key={a.id}>
|
|
|
+ <span>{a.contact_name || a.phone} · {a.phone}</span><div><button className="primary-button small-button" onClick={() => review(a.id, true)}>同意</button>
|
|
|
+ <button className="secondary-button small-button" onClick={() => review(a.id, false)}>拒绝</button></div></div>)}
|
|
|
+ </section>
|
|
|
+ <section className="admin-section-block">
|
|
|
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginBottom: '12px' }}>
|
|
|
+ <h2 style={{ margin: 0 }}>课程管理</h2>
|
|
|
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
|
+ <label style={{ fontSize: '0.85rem', color: '#64748b' }}>排序:</label>
|
|
|
+ <select
|
|
|
+ style={{ padding: '4px 8px', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem', background: '#fff' }}
|
|
|
+ value={`${courseSortField}-${courseSortOrder}`}
|
|
|
+ onChange={(e) => {
|
|
|
+ const [f, o] = e.target.value.split('-') as [CourseSortField, SortOrder]
|
|
|
+ setCourseSortField(f)
|
|
|
+ setCourseSortOrder(o)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <option value="updated_at-desc">最后更新时间(最新优先)</option>
|
|
|
+ <option value="updated_at-asc">最后更新时间(最早优先)</option>
|
|
|
+ <option value="created_at-desc">创建时间(最新优先)</option>
|
|
|
+ <option value="created_at-asc">创建时间(最早优先)</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="admin-table-wrapper">
|
|
|
+ <table className="admin-table">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>课程</th>
|
|
|
+ <th>老师</th>
|
|
|
+ <th>状态</th>
|
|
|
+ <th>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
|
|
+ onClick={() => toggleCourseSort('updated_at')}
|
|
|
+ title="点击切换按最后更新时间排序"
|
|
|
+ >
|
|
|
+ 最后更新时间 {courseSortField === 'updated_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
|
|
|
+ </button>
|
|
|
+ </th>
|
|
|
+ <th>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ style={{ background: 'none', border: 'none', padding: 0, font: 'inherit', fontWeight: 600, color: 'inherit', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
|
|
+ onClick={() => toggleCourseSort('created_at')}
|
|
|
+ title="点击切换按创建时间排序"
|
|
|
+ >
|
|
|
+ 创建时间 {courseSortField === 'created_at' ? (courseSortOrder === 'desc' ? '↓' : '↑') : '↕'}
|
|
|
+ </button>
|
|
|
+ </th>
|
|
|
+ <th>操作</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {sortedCourses.length === 0 ? (
|
|
|
+ <tr>
|
|
|
+ <td colSpan={6} style={{ textAlign: 'center', color: '#64748b', padding: '24px' }}>
|
|
|
+ 暂无组织课程
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ ) : (
|
|
|
+ sortedCourses.map((c: any) => (
|
|
|
+ <tr key={c.id}>
|
|
|
+ <td><strong>{c.name}</strong></td>
|
|
|
+ <td>{c.creator_name || '未填写'}</td>
|
|
|
+ <td><StatusBadge status={c.status as CourseStatus} /></td>
|
|
|
+ <td><span style={{ fontSize: '0.85rem', color: '#334155' }}>{formatDateTime(c.updated_at || c.created_at)}</span></td>
|
|
|
+ <td><span style={{ fontSize: '0.85rem', color: '#64748b' }}>{formatDateTime(c.created_at)}</span></td>
|
|
|
+ <td>
|
|
|
+ <div className="action-button-group">
|
|
|
+ <Link className="secondary-button small-button" to={`/organizations/${slug}/manage/courses/${c.id}`}>制作与积分</Link>
|
|
|
+ <Link className="secondary-button small-button" to={`/courses/${c.id}`} onClick={() => selectWorkspace(organization.id)}>代修改内容</Link>
|
|
|
+ <button className="secondary-button small-button" onClick={async () => {
|
|
|
+ if (!confirm(`删除课程「${c.name}」及其素材?已结算积分不会自动退回,账单和操作记录会保留。`)) return
|
|
|
+ selectWorkspace(organization.id)
|
|
|
+ try { await api.deleteCourse(c.id); await load() } catch (x) { setError((x as Error).message) }
|
|
|
+ }}>删除</button>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <section className="admin-section-block"><h2>人员名单</h2><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>姓名</th><th>手机号</th><th>组织身份</th><th>操作</th></tr></thead><tbody>
|
|
|
+ {manage.members.map((m: any) => <tr key={m.id}><td>{editingMemberId === m.id ? <input aria-label={`${m.phone} 的姓名`} value={editingMemberName} maxLength={80} autoFocus onChange={(e) => setEditingMemberName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveMemberName(m.id); if (e.key === 'Escape') setEditingMemberId(null) }} /> : (m.contact_name || '未填写')}</td><td>{m.phone}</td><td>{m.role === 'MANAGER' ? '组织管理员' : '成员'}</td><td>{editingMemberId === m.id ? <div className="action-button-group"><button className="primary-button small-button" disabled={savingMemberName} onClick={() => saveMemberName(m.id)}>{savingMemberName ? '保存中…' : '保存'}</button><button className="secondary-button small-button" disabled={savingMemberName} onClick={() => setEditingMemberId(null)}>取消</button></div> : <button className="secondary-button small-button" onClick={() => { setEditingMemberId(m.id); setEditingMemberName(m.contact_name || '') }}>修改姓名</button>}</td></tr>)}
|
|
|
+ </tbody></table></div></section>
|
|
|
+ <section className="admin-section-block"><h2>组织积分流水</h2><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>时间</th><th>事项</th><th>老师</th><th>积分变化</th></tr></thead><tbody>
|
|
|
+ {manage.ledger.map((l: any) => <tr key={l.id}><td>{new Date(l.created_at).toLocaleString('zh-CN')}</td><td>{l.reason}</td><td>{l.beneficiary_name || '组织账户'}</td><td>{l.amount}</td></tr>)}
|
|
|
+ </tbody></table></div></section>
|
|
|
+ <section className="admin-section-block">
|
|
|
+ <div
|
|
|
+ style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}
|
|
|
+ onClick={() => setShowAudit((prev) => !prev)}
|
|
|
+ >
|
|
|
+ <h2 style={{ margin: 0 }}>操作记录</h2>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className="secondary-button small-button"
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ setShowAudit((prev) => !prev)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {showAudit ? '收起记录' : '展开记录'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ {showAudit && (
|
|
|
+ <div style={{ marginTop: '16px' }}>
|
|
|
+ {manage.audit?.length === 0 ? (
|
|
|
+ <p style={{ color: '#64748b', marginTop: '12px' }}>暂无操作记录</p>
|
|
|
+ ) : (
|
|
|
+ manage.audit.map((a: any) => (
|
|
|
+ <details className="org-audit-event" key={a.id}>
|
|
|
+ <summary>{a.actor_name || '系统'} · {a.action} · {new Date(a.created_at).toLocaleString('zh-CN')}</summary>
|
|
|
+ <pre>{JSON.stringify(a.details, null, 2)}</pre>
|
|
|
+ </details>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </section>
|
|
|
+ </>}
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+}
|