Commit 86bbaf04 authored by drigle's avatar drigle

fix: use modal confirmations and adjust filter tree defaults

parent a288b353
......@@ -34,13 +34,23 @@ function useSubjectRecords(subjectName, groupName) {
function ProcessListView({ subjectName, config }) {
const groupSubject = config?.group_subject || 'process_flow_group';
const [groups, setGroups] = useState([]); const [active, setActive] = useState(''); const [modal, setModal] = useState(false); const [draft, setDraft] = useState({}); const [saving, setSaving] = useState(false); const [error, setError] = useState('');
const [groups, setGroups] = useState([]); const [active, setActive] = useState(''); const [modal, setModal] = useState(false); const [draft, setDraft] = useState({}); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteError, setDeleteError] = useState(''); const [error, setError] = useState('');
const { group, items, loading, error: groupError, reload } = useSubjectRecords(subjectName, active);
const loadGroups = async () => { try { const result = await loadRecords(groupSubject, 0, 100); const next = result?.records || []; setGroups(next); setActive((current) => current || next[0]?.name || ''); } catch (requestError) { setError(requestError?.message || '流程分组加载失败'); } };
useEffect(() => { loadGroups(); }, [groupSubject]);
const save = async () => { setSaving(true); setError(''); try { if (draft.group_name !== undefined) await createRecord({ subject: groupSubject, title: draft.group_name, metadata: { group_name: draft.group_name, process_list: [] } }); else if (draft.editName) await updateRecord(draft.editName, { title: draft.title, metadata: { remark: draft.remark, source: draft.source || null } }); else { const created = await createRecord({ subject: subjectName, title: draft.title, metadata: { remark: draft.remark, source: draft.source || null } }); await updateRecord(group.name, { metadata: { ...group.metadata, process_list: [...(group.metadata?.process_list || []).map(valueOf), created._id] } }); } setModal(false); setDraft({}); await loadGroups(); await reload(); } catch (requestError) { setError(requestError?.message || '保存失败'); } finally { setSaving(false); } };
const deleteProcess = async (item) => { if (!group || !window.confirm('确定删除该流程吗?')) return; await removeRecord(item.name || item._id); await updateRecord(group.name, { metadata: { ...group.metadata, process_list: (group.metadata?.process_list || []).map(valueOf).filter((id) => id !== (item._id || item.name)) } }); await reload(); };
return <div className="rc-page-stack"><h2 className="mb-4 text-xl font-semibold">流程管理</h2>{error || groupError ? <Alert status="danger"><Alert.Content><Alert.Description>{error || groupError}</Alert.Description></Alert.Content></Alert> : null}<GroupTabs groups={groups} active={active} onChange={setActive} onCreate={() => { setDraft({ group_name: '' }); setModal(true); }} label="流程分组" /><Card><Card.Header><Card.Title className="mr-auto">{group?.title || '请选择流程分组'}</Card.Title>{group ? <Button variant="primary" onPress={() => { setDraft({}); setModal(true); }}>新增流程</Button> : null}</Card.Header><Card.Content>{loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : !items.length ? <div className="py-16 text-center text-sm text-muted">暂无流程</div> : <ol className="space-y-3">{items.map((item, index) => <li className="flex items-center gap-3 rounded-lg border border-default p-3" key={item.name || item._id}><span className="grid h-8 w-8 place-items-center rounded-full bg-accent/10 text-sm font-semibold text-accent">{index + 1}</span><div className="min-w-0 flex-1"><div className="truncate font-medium">{item.title || item.name}</div><div className="text-sm text-muted">{item.metadata?.remark || '暂无说明'}</div></div><Button size="sm" variant="secondary" onPress={() => { setDraft({ ...item.metadata, title: item.title, editName: item.name }); setModal(true); }}>编辑</Button><Button size="sm" variant="secondary" onPress={() => history.push(`/${subjectName}/${item.name || item._id}`)}>查看</Button><Button size="sm" variant="danger" onPress={() => deleteProcess(item)}>删除</Button></li>)}</ol>}</Card.Content></Card><XinyuanModal isOpen={modal} onOpenChange={setModal} title={draft.group_name !== undefined ? '新建流程分组' : draft.editName ? '编辑流程' : '新建流程'} footer={<><Button variant="secondary" onPress={() => setModal(false)}>取消</Button><Button variant="primary" isPending={saving} onPress={save}>保存</Button></>}><div className="grid gap-4">{draft.group_name !== undefined ? <Input label="分组名称" value={draft.group_name} onChange={(event) => setDraft({ ...draft, group_name: event.target.value })} /> : <><Input label="流程名称" value={draft.title || ''} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /><TextArea label="流程说明" value={draft.remark || ''} onChange={(event) => setDraft({ ...draft, remark: event.target.value })} /></>}</div></XinyuanModal></div>;
const confirmDeleteProcess = async () => {
if (!group || !deleteTarget) return;
setDeleting(true); setDeleteError('');
try {
await removeRecord(deleteTarget.name || deleteTarget._id);
await updateRecord(group.name, { metadata: { ...group.metadata, process_list: (group.metadata?.process_list || []).map(valueOf).filter((id) => id !== (deleteTarget._id || deleteTarget.name)) } });
setDeleteTarget(null);
await reload();
} catch (requestError) { setDeleteError(requestError?.message || '删除失败'); }
finally { setDeleting(false); }
};
return <div className="rc-page-stack"><h2 className="mb-4 text-xl font-semibold">流程管理</h2>{error || groupError ? <Alert status="danger"><Alert.Content><Alert.Description>{error || groupError}</Alert.Description></Alert.Content></Alert> : null}<GroupTabs groups={groups} active={active} onChange={setActive} onCreate={() => { setDraft({ group_name: '' }); setModal(true); }} label="流程分组" /><Card><Card.Header><Card.Title className="mr-auto">{group?.title || '请选择流程分组'}</Card.Title>{group ? <Button variant="primary" onPress={() => { setDraft({}); setModal(true); }}>新增流程</Button> : null}</Card.Header><Card.Content>{loading ? <div className="flex min-h-32 items-center justify-center"><Spinner /></div> : !items.length ? <div className="py-16 text-center text-sm text-muted">暂无流程</div> : <ol className="space-y-3">{items.map((item, index) => <li className="flex items-center gap-3 rounded-lg border border-default p-3" key={item.name || item._id}><span className="grid h-8 w-8 place-items-center rounded-full bg-accent/10 text-sm font-semibold text-accent">{index + 1}</span><div className="min-w-0 flex-1"><div className="truncate font-medium">{item.title || item.name}</div><div className="text-sm text-muted">{item.metadata?.remark || '暂无说明'}</div></div><Button size="sm" variant="secondary" onPress={() => { setDraft({ ...item.metadata, title: item.title, editName: item.name }); setModal(true); }}>编辑</Button><Button size="sm" variant="secondary" onPress={() => history.push(`/${subjectName}/${item.name || item._id}`)}>查看</Button><Button size="sm" variant="danger" onPress={() => { setDeleteError(''); setDeleteTarget(item); }}>删除</Button></li>)}</ol>}</Card.Content></Card><XinyuanModal isOpen={Boolean(deleteTarget)} onOpenChange={(open) => { if (!open && !deleting) setDeleteTarget(null); }} title="删除流程" footer={<><Button variant="secondary" isDisabled={deleting} onPress={() => setDeleteTarget(null)}>取消</Button><Button variant="danger" isPending={deleting} onPress={confirmDeleteProcess}>确认删除</Button></>}><p>确定删除“{titleOf(deleteTarget)}”吗?</p>{deleteError ? <p className="mt-3 text-danger" role="alert">{deleteError}</p> : null}</XinyuanModal><XinyuanModal isOpen={modal} onOpenChange={setModal} title={draft.group_name !== undefined ? '新建流程分组' : draft.editName ? '编辑流程' : '新建流程'} footer={<><Button variant="secondary" onPress={() => setModal(false)}>取消</Button><Button variant="primary" isPending={saving} onPress={save}>保存</Button></>}><div className="grid gap-4">{draft.group_name !== undefined ? <Input label="分组名称" value={draft.group_name} onChange={(event) => setDraft({ ...draft, group_name: event.target.value })} /> : <><Input label="流程名称" value={draft.title || ''} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /><TextArea label="流程说明" value={draft.remark || ''} onChange={(event) => setDraft({ ...draft, remark: event.target.value })} /></>}</div></XinyuanModal></div>;
}
export default function ConfiguredSpecialViews({ view, subjectName, config }) {
......
......@@ -202,6 +202,9 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) {
const value = record?.metadata?.[field];
const attachments = Array.isArray(value) ? value : value ? [value] : [];
const [uploading, setUploading] = useState(false);
const [removing, setRemoving] = useState(false);
const [removeTarget, setRemoveTarget] = useState(null);
const [removeError, setRemoveError] = useState('');
const uploadFiles = async (files) => {
if (!field || !files?.length) return;
......@@ -217,10 +220,19 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) {
}
};
const removeAttachment = async (attachment) => {
if (!field || !window.confirm('确定移除这个附件吗?')) return;
const key = attachment?._id || attachment?.url || attachment?.name || attachment?.title;
const confirmRemoveAttachment = async () => {
if (!field || !removeTarget) return;
const key = removeTarget?._id || removeTarget?.url || removeTarget?.name || removeTarget?.title;
setRemoving(true);
setRemoveError('');
try {
await onPatchMetadata(field, attachments.filter((item) => (item?._id || item?.url || item?.name || item?.title) !== key));
setRemoveTarget(null);
} catch (error) {
setRemoveError(error?.message || '附件移除失败');
} finally {
setRemoving(false);
}
};
return (
......@@ -235,7 +247,7 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) {
<a href={item?.url || '#'} rel="noreferrer" target="_blank">
{item?.title || item?.name || `附件 ${index + 1}`}
</a>
<Button isIconOnly aria-label="移除附件" size="sm" variant="tertiary" onPress={() => removeAttachment(item)}>
<Button isIconOnly aria-label="移除附件" size="sm" variant="tertiary" onPress={() => { setRemoveError(''); setRemoveTarget(item); }}>
<Close size={14} />
</Button>
</div>
......@@ -251,6 +263,21 @@ function AttachmentPanel({ config, record, onPatchMetadata, setError }) {
onFilesChange={uploadFiles}
/>
</Card.Content>
<XinyuanModal
footer={(
<>
<Button isDisabled={removing} variant="secondary" onPress={() => setRemoveTarget(null)}>取消</Button>
<Button isPending={removing} variant="danger" onPress={confirmRemoveAttachment}>确认移除</Button>
</>
)}
isOpen={Boolean(removeTarget)}
onOpenChange={(open) => { if (!open && !removing) setRemoveTarget(null); }}
size="sm"
title="移除附件"
>
<p>确定移除“{removeTarget?.title || removeTarget?.name || removeTarget?.url || '该附件'}”吗?</p>
{removeError ? <p className="mt-3 text-danger" role="alert">{removeError}</p> : null}
</XinyuanModal>
</Card>
);
}
......@@ -339,6 +366,8 @@ function LawRecordDetail() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const [deletingRecord, setDeletingRecord] = useState(false);
const [deleteRecordOpen, setDeleteRecordOpen] = useState(false);
const [historyItems, setHistoryItems] = useState([]);
const [error, setError] = useState('');
const [collections, setCollections] = useState(() => loadStorageArray(COLLECTION_STORAGE_KEY));
......@@ -569,9 +598,10 @@ function LawRecordDetail() {
};
const deleteRecord = async () => {
if (!recordName || !window.confirm('确定删除这条记录吗?')) return;
if (!recordName) return;
try {
await removeRecord(recordName);
setDeleteRecordOpen(false);
history.push(`/${subjectName}`);
} catch (requestError) {
setError(requestError?.message || '删除失败');
......@@ -614,7 +644,7 @@ function LawRecordDetail() {
<TableActionButton icon={isCollected ? StarSolid : Star} label={isCollected ? '取消收藏' : '收藏'} onPress={toggleCollection} />
<TableActionButton icon={ArrowDownTrayIcon} isDisabled={!firstFile} label="下载" onPress={() => firstFile?.url && window.open(firstFile.url, '_blank', 'noopener,noreferrer')} />
<TableActionButton icon={Edit} label="编辑" onPress={openEditModal} />
<TableActionButton icon={Delete} label="删除" tone="delete" onPress={deleteRecord} />
<TableActionButton icon={Delete} label="删除" tone="delete" onPress={() => { setError(''); setDeleteRecordOpen(true); }} />
</TableActionGroup>
) : null}
</div>
......@@ -790,6 +820,22 @@ function LawRecordDetail() {
</div>
</section>
</XinyuanModal>
<XinyuanModal
footer={(
<>
<Button isDisabled={deletingRecord} variant="secondary" onPress={() => setDeleteRecordOpen(false)}>取消</Button>
<Button isPending={deletingRecord} variant="danger" onPress={deleteRecord}>确认删除</Button>
</>
)}
isOpen={deleteRecordOpen}
onOpenChange={(open) => { if (!open && !deletingRecord) setDeleteRecordOpen(false); }}
size="sm"
title="删除记录"
>
<p>确定删除“{record?.title || record?.name}”吗?</p>
{error ? <p className="mt-3 text-danger" role="alert">{error}</p> : null}
</XinyuanModal>
</div>
);
}
......@@ -1302,23 +1302,9 @@ function SearchTable({ records, fields, subjectName, onEdit, onRemove }) {
/>;
}
function collectExpandableTreeKeys(nodes = []) {
return nodes.flatMap((node) => node.children?.length ? [node.key, ...collectExpandableTreeKeys(node.children)] : []);
}
function FilterTreeCard({ title, nodes, selectedKeys, onSelect }) {
const [open, setOpen] = useState(false);
const [expandedKeys, setExpandedKeys] = useState(() => collectExpandableTreeKeys(nodes));
useEffect(() => {
const availableKeys = new Set(collectExpandableTreeKeys(nodes));
setExpandedKeys((current) => {
const retained = current.filter((key) => availableKeys.has(key));
const added = [...availableKeys].filter((key) => !current.includes(key));
const next = [...retained, ...added];
return next.length === current.length && next.every((key, index) => key === current[index]) ? current : next;
});
}, [nodes]);
const [open, setOpen] = useState(true);
const [expandedKeys, setExpandedKeys] = useState([]);
return (
<section className="law-filter-tree-card">
......
......@@ -6,7 +6,9 @@
## 浏览与检索
用户进入 Subject 列表后,页面调用 `POST /record/query`,按配置中的 `list.view`、排序和搜索 Subject 展示记录。`search_view`/`search` 使用 `POST /search/:subject` 的全文检索结果,`table` 使用 Record 查询结果渲染表格,`column` 使用同一结果按 `metadata.level` 分组渲染可滚动列看板,`card` 使用同一结果渲染卡片列表;`search` 额外支持模糊搜索和聚合字段筛选,`column` 对 Subject 的 select 字段提供筛选。法规模块的输入框、按钮、勾选框、卡片和反馈状态统一使用 HeroUI,搜索正文与模糊搜索使用显式 HeroUI Checkbox;搜索页桌面端沿用 `streams-admin` 的左右工作区,左侧约四分之一宽度显示聚合筛选,右侧约四分之三宽度依次显示搜索工具区和结果区,两个区域顶部对齐。搜索工具区将搜索框、检索选项和导入/导出/新建操作放在同一面板中,在右侧内容列内吸顶并停留在顶部导航栏下方;左侧筛选栏同步吸顶并独立滚动。中小屏切换为搜索工具区、筛选区、结果区的纵向结构,避免压缩搜索输入和操作按钮;导入、导出和新建使用统一尺寸的图标按钮,通过可访问名称和悬浮提示说明操作,操作区顶边与搜索框首行对齐。左侧聚合筛选分组默认收起,并与带子节点的目录项一样支持独立展开、折叠;面板不得压缩隐藏目录项。列表分页加载,前端页码从 0 开始传入接口,底部提供 20/50/100 条每页切换;切换页容量、关键词、模糊搜索或筛选条件后回到第一页重新查询,总数分别读取搜索接口的 `hits.total.value` 或 Record 查询接口返回的 `total_records`/`total`/`count`。表格和卡片关键词输入采用延迟查询,Subject 搜索页通过回车或搜索按钮提交,搜索结果按标题、正文摘要和标签分层展示;每条结果使用紧凑的轻边框卡片,摘要展开按钮独立于正文段落,避免长列表展开时结构错位。记录标题和 metadata 字段统一展示,点击“查看”进入 `/:subject/:record` 详情,删除使用 `DELETE /record/:name` 软删除。`/search` 为全局检索入口,调用 `GET /subject` 和 `GET /search/aggregate`(通过 `aggregateSearch` 封装)按 Subject 分组展示结果,分组切换沿用历史应用的 Subject 标签页逻辑;点击全局搜索结果进入详情时携带 `keyword` 查询参数,详情页自动进入关键词查找和定位流程。`audit_indicator_list` 使用指标分组、指标新增和执行结果记录流程;`process_list` 使用流程分组、流程新增和流程记录详情流程。
用户进入 Subject 列表后,页面调用 `POST /record/query`,按配置中的 `list.view`、排序和搜索 Subject 展示记录。`search_view`/`search` 使用 `POST /search/:subject` 的全文检索结果,`table` 使用 Record 查询结果渲染表格,`column` 使用同一结果按 `metadata.level` 分组渲染可滚动列看板,`card` 使用同一结果渲染卡片列表;`search` 额外支持模糊搜索和聚合字段筛选,`column` 对 Subject 的 select 字段提供筛选。法规模块的输入框、按钮、勾选框、卡片和反馈状态统一使用 HeroUI,搜索正文与模糊搜索使用显式 HeroUI Checkbox;搜索页桌面端沿用 `streams-admin` 的左右工作区,左侧约四分之一宽度显示聚合筛选,右侧约四分之三宽度依次显示搜索工具区和结果区,两个区域顶部对齐。搜索工具区将搜索框、检索选项和导入/导出/新建操作放在同一面板中,在右侧内容列内吸顶并停留在顶部导航栏下方;左侧筛选栏同步吸顶并独立滚动。中小屏切换为搜索工具区、筛选区、结果区的纵向结构,避免压缩搜索输入和操作按钮;导入、导出和新建使用统一尺寸的图标按钮,通过可访问名称和悬浮提示说明操作,操作区顶边与搜索框首行对齐。左侧聚合筛选外层卡片默认展开,内部树节点默认只显示根级并支持逐级展开、折叠;面板不得压缩隐藏目录项。列表分页加载,前端页码从 0 开始传入接口,底部提供 20/50/100 条每页切换;切换页容量、关键词、模糊搜索或筛选条件后回到第一页重新查询,总数分别读取搜索接口的 `hits.total.value` 或 Record 查询接口返回的 `total_records`/`total`/`count`。表格和卡片关键词输入采用延迟查询,Subject 搜索页通过回车或搜索按钮提交,搜索结果按标题、正文摘要和标签分层展示;每条结果使用紧凑的轻边框卡片,摘要展开按钮独立于正文段落,避免长列表展开时结构错位。记录标题和 metadata 字段统一展示,点击“查看”进入 `/:subject/:record` 详情,删除使用 `DELETE /record/:name` 软删除。`/search` 为全局检索入口,调用 `GET /subject` 和 `GET /search/aggregate`(通过 `aggregateSearch` 封装)按 Subject 分组展示结果,分组切换沿用历史应用的 Subject 标签页逻辑;点击全局搜索结果进入详情时携带 `keyword` 查询参数,详情页自动进入关键词查找和定位流程。`audit_indicator_list` 使用指标分组、指标新增和执行结果记录流程;`process_list` 使用流程分组、流程新增和流程记录详情流程。
配置为 `process_list` 时,删除流程前通过 HeroUI Modal 二次确认;确认后调用 `DELETE /record/:name` 软删除流程,并从当前分组的 `metadata.process_list` 中移除对应 ID。失败时保留确认弹窗并显示错误,成功后刷新分组关联和流程列表。
`table` 视图按历史 RecordTable 的常用操作对齐:表头字段可切换升序/降序排序,勾选框支持当前页批量选择,批量删除逐条调用 `DELETE /record/:name` 并刷新列表;导出会按当前 Subject 字段读取全量分页数据并生成 Excel。`card` 视图保留卡片列表展示和查看、下载、编辑、删除操作,也支持 20/50/100 分页与 Excel 导出;卡片内部统一使用标题区、两列字段信息区和底部操作区,窄屏退化为单列字段,操作图标保留 tooltip 和可访问名称。`table`、`card`、`search` 和 `search_view` 顶部均提供导入入口,其中“导入目录”读取 Excel/CSV 第一张表,以字段标签或字段别名映射到 Subject 字段后调用 `POST /record/import`;“导入文档”上传 Word/PDF,优先转换正文并在 Word 文档可解析时写入 `metadata.document`,再调用 `POST /record` 创建记录。
......@@ -20,7 +22,7 @@
## 详情与附件
详情字段、正文模板和附件侧栏使用应用配置中的 `detail`;`article_view` 显示配置模板,其他详情模式使用结构化字段视图。详情页顶部提供返回列表、收藏、下载、编辑和删除入口,顶部标题、操作按钮和正文阅读工具栏在滚动正文时保持吸顶,收藏状态保存在本地浏览器状态中,下载使用当前记录正文、文档或附件中的第一个可访问文件。`detail.content.type` 为 `attachment` 时,正文区域按 `streams-admin` 的 ArticleView 规则从当前记录文件中选择首个 PDF 并直接内嵌预览,同时提供新窗口打开入口;没有可预览 PDF 时回退到配置模板中的附件链接或空状态。普通正文区域按模板安全渲染 HTML,并提供查找关键词、上一处/下一处命中、字号调整、字体色、背景色和引用识别开关。字号由正文容器统一继承并覆盖正文 HTML 中可能残留的内联字号,确保调整后段落、表格和标题同步变化。开启引用识别后,先从正文文本提取 `《…》` 标题,再通过 `POST /record/query` 在当前 Subject 中按 `title in [...]` 查询真实记录;只有匹配到的记录会渲染为新窗口链接,当前记录自身会排除,查询失败时保留正文并显示轻量错误提示。`detail.side_info` 配置为 `attachment` 时显示右侧附件列表,可追加上传并移除附件;`related`、`reference`、`history`、`category` 等配置按历史应用的侧栏信息区展示。日期、选择、长文本字段保留现有 HeroUI 控件风格;附件值按 Record API 的 `{name,type,size,url}` 结构保存。
详情字段、正文模板和附件侧栏使用应用配置中的 `detail`;`article_view` 显示配置模板,其他详情模式使用结构化字段视图。详情页顶部提供返回列表、收藏、下载、编辑和删除入口,删除记录前通过 HeroUI Modal 二次确认;确认执行软删除,失败时保留弹窗并显示错误。顶部标题、操作按钮和正文阅读工具栏在滚动正文时保持吸顶,收藏状态保存在本地浏览器状态中,下载使用当前记录正文、文档或附件中的第一个可访问文件。`detail.content.type` 为 `attachment` 时,正文区域按 `streams-admin` 的 ArticleView 规则从当前记录文件中选择首个 PDF 并直接内嵌预览,同时提供新窗口打开入口;没有可预览 PDF 时回退到配置模板中的附件链接或空状态。普通正文区域按模板安全渲染 HTML,并提供查找关键词、上一处/下一处命中、字号调整、字体色、背景色和引用识别开关。字号由正文容器统一继承并覆盖正文 HTML 中可能残留的内联字号,确保调整后段落、表格和标题同步变化。开启引用识别后,先从正文文本提取 `《…》` 标题,再通过 `POST /record/query` 在当前 Subject 中按 `title in [...]` 查询真实记录;只有匹配到的记录会渲染为新窗口链接,当前记录自身会排除,查询失败时保留正文并显示轻量错误提示。`detail.side_info` 配置为 `attachment` 时显示右侧附件列表,可追加上传并移除附件;移除附件前通过 HeroUI Modal 二次确认,确认后更新对应 metadata 字段,失败时保留弹窗并显示错误。`related`、`reference`、`history`、`category` 等配置按历史应用的侧栏信息区展示。日期、选择、长文本字段保留现有 HeroUI 控件风格;附件值按 Record API 的 `{name,type,size,url}` 结构保存。
## 权限与异常
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment