feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件
写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。 这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**—— 能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。 后端 - attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定, 换对象存储只改 services/storage.py 一个文件 - 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头 - 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求 - raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员, 非成员一律 8001 不区分「不存在」与「没权限」 - 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权 前端 - 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单 - RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用 - AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob - 任务备注、工单说明与验收结论、知识正文、评论全部接入 顺手修的 - TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的) 71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB; e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { api, session, uploadFile } from "../lib/client";
|
||||
import { Download, Paperclip, Plus, Spinner, Trash } from "./icons";
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
item_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
uploader_id: string;
|
||||
created_at: string;
|
||||
can_delete: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
wsId: string;
|
||||
itemId: string;
|
||||
attachments: Attachment[];
|
||||
onChange: (next: Attachment[]) => void;
|
||||
onError?: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function humanSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目附件(任务 / 工单 / 知识共用)。
|
||||
*
|
||||
* 下载走一次带 Authorization 头的 fetch 再转 blob:
|
||||
* 直接用 <a href> 会把令牌拼进 URL,留在浏览器历史里不值得。
|
||||
*/
|
||||
export function AttachmentPanel({
|
||||
wsId,
|
||||
itemId,
|
||||
attachments,
|
||||
onChange,
|
||||
onError,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
async function addFiles(files: File[]) {
|
||||
if (files.length === 0) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const created: Attachment[] = [];
|
||||
for (const file of files) {
|
||||
created.push(
|
||||
await uploadFile<Attachment>(`/workspaces/${wsId}/items/${itemId}/attachments`, file),
|
||||
);
|
||||
}
|
||||
onChange([...created, ...attachments]);
|
||||
} catch (err) {
|
||||
onError?.(err instanceof Error ? err.message : "上传失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function download(row: Attachment) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/v1/workspaces/${wsId}/attachments/${row.id}/download`,
|
||||
{ headers: { Authorization: `Bearer ${session.access ?? ""}` } },
|
||||
);
|
||||
if (!resp.ok) throw new Error(`下载失败(HTTP ${resp.status})`);
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = row.filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
onError?.(err instanceof Error ? err.message : "下载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: Attachment) {
|
||||
if (!window.confirm(`删除附件「${row.filename}」?正文里的引用会变成坏链。`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
// 后端要求显式确认:删除不可逆,前端这一步只是二次确认
|
||||
await api.del(`/workspaces/${wsId}/attachments/${row.id}?confirm=true`);
|
||||
onChange(attachments.filter((item) => item.id !== row.id));
|
||||
} catch (err) {
|
||||
onError?.(err instanceof Error ? err.message : "删除失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={(event) => {
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
const files = Array.from(event.dataTransfer.files ?? []);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void addFiles(files);
|
||||
}
|
||||
}}
|
||||
className={dragging ? "rounded-xl ring-2 ring-primary-500/40" : ""}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-2xs font-medium text-ink-500">
|
||||
<Paperclip size={13} />
|
||||
附件 {attachments.length > 0 && <span className="text-ink-400">· {attachments.length}</span>}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-2xs text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
|
||||
>
|
||||
{busy ? <Spinner size={12} /> : <Plus size={12} />}
|
||||
上传
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{attachments.length === 0 ? (
|
||||
<p className="mt-1.5 rounded-lg border border-dashed border-line-200 px-3 py-2 text-2xs text-ink-400">
|
||||
可以把文件拖到这里,或点「上传」。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{attachments.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="group flex items-center gap-2 rounded-lg px-2 py-1.5 transition hover:bg-paper-200"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-ink-700">{row.filename}</span>
|
||||
<span className="shrink-0 text-2xs text-ink-400">{humanSize(row.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download(row)}
|
||||
title="下载"
|
||||
aria-label={`下载 ${row.filename}`}
|
||||
className="grid h-6 w-6 place-items-center rounded text-ink-400 opacity-0 transition hover:bg-paper-300 hover:text-ink-700 group-hover:opacity-100"
|
||||
>
|
||||
<Download size={13} />
|
||||
</button>
|
||||
{row.can_delete && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove(row)}
|
||||
title="删除"
|
||||
aria-label={`删除 ${row.filename}`}
|
||||
className="grid h-6 w-6 place-items-center rounded text-ink-400 opacity-0 transition hover:bg-rust-50 hover:text-rust-600 group-hover:opacity-100"
|
||||
>
|
||||
<Trash size={13} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { renderMarkdown } from "../lib/markdown";
|
||||
|
||||
interface Props {
|
||||
source: string;
|
||||
/** 列表里只给个开头时用它压小字号 */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读的 Markdown 渲染。
|
||||
*
|
||||
* renderMarkdown 内部已经把原文全部 HTML 转义过再套标签,
|
||||
* 这里放心用 dangerouslySetInnerHTML(这是本仓库唯一允许用的地方)。
|
||||
*/
|
||||
export function Markdown({ source, compact = false, className = "" }: Props) {
|
||||
const html = useMemo(() => renderMarkdown(source ?? ""), [source]);
|
||||
|
||||
if (!source?.trim()) {
|
||||
return <p className={`text-sm text-ink-400 ${className}`}>还没有内容。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"prose-dadao text-sm leading-6 text-ink-800",
|
||||
compact ? "prose-compact" : "",
|
||||
className,
|
||||
].join(" ")}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { attachmentUrl, uploadFile } from "../lib/client";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { Bold, Code, Eye, Image as ImageIcon, Italic, Link2, ListIcon, Pencil, Quote } from "./icons";
|
||||
|
||||
interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
/** 图片要落到具体条目下,所以这两个是必填 */
|
||||
wsId: string;
|
||||
itemId: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
rows?: number;
|
||||
ariaLabel: string;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本编辑器。
|
||||
*
|
||||
* 存储格式是 Markdown——为什么不做 contenteditable 的富文本:
|
||||
* 那样存进去的是 HTML,导出、检索、喂给 LLM 都要再清洗一遍;而 Markdown 天生
|
||||
* 既是纯文本又能表达排版,模型读得懂、git diff 也看得懂。
|
||||
*
|
||||
* 写作时不用记语法:工具栏按钮、Ctrl+B/I、粘贴/拖拽图片,都会就地改写文本。
|
||||
*/
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
wsId,
|
||||
itemId,
|
||||
placeholder = "写点什么…支持 Markdown:**粗体**、- 列表、```代码```",
|
||||
disabled = false,
|
||||
rows = 6,
|
||||
ariaLabel,
|
||||
onError,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const helpId = useId();
|
||||
|
||||
/** 在光标处插入文本;有选区就包裹它,插完把光标放到合适位置。 */
|
||||
const surround = useCallback(
|
||||
(before: string, after = "", placeholderText = "") => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const { selectionStart: start, selectionEnd: end } = el;
|
||||
const picked = value.slice(start, end);
|
||||
const selected = picked || placeholderText;
|
||||
const next = value.slice(0, start) + before + selected + after + value.slice(end);
|
||||
|
||||
onChange(next);
|
||||
// 等 React 把新值写回 textarea 再摆光标,否则会被旧值覆盖
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
if (picked) {
|
||||
// 有选区:保持包裹后的内容仍被选中,方便继续加别的样式
|
||||
el.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||
} else {
|
||||
const cursor = start + before.length + selected.length;
|
||||
el.setSelectionRange(cursor, cursor);
|
||||
}
|
||||
});
|
||||
},
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
const prefixLines = useCallback(
|
||||
(prefix: string) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const start = value.lastIndexOf("\n", el.selectionStart - 1) + 1;
|
||||
const endIdx = value.indexOf("\n", el.selectionEnd);
|
||||
const end = endIdx === -1 ? value.length : endIdx;
|
||||
const block = value.slice(start, end) || "列表项";
|
||||
const nextBlock = block
|
||||
.split("\n")
|
||||
.map((line) => `${prefix}${line.replace(/^(\s*)([-*+]|\d+[.)])\s+/, "$1")}`)
|
||||
.join("\n");
|
||||
onChange(value.slice(0, start) + nextBlock + value.slice(end));
|
||||
requestAnimationFrame(() => el.focus());
|
||||
},
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
const insertImages = useCallback(
|
||||
async (files: File[]) => {
|
||||
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||
if (images.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
let next = value;
|
||||
for (const file of images) {
|
||||
const created = await uploadFile<Attachment>(
|
||||
`/workspaces/${wsId}/items/${itemId}/attachments`,
|
||||
file,
|
||||
);
|
||||
const snippet = `\n})\n`;
|
||||
next += snippet;
|
||||
}
|
||||
onChange(next);
|
||||
} catch (err) {
|
||||
onError?.(err instanceof Error ? err.message : "图片上传失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[itemId, onError, onChange, value, wsId],
|
||||
);
|
||||
|
||||
// 用 document 级别的粘贴监听兜住"焦点在 textarea 但剪贴板里是图片"的情况
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || disabled) return;
|
||||
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const files = Array.from(event.clipboardData?.files ?? []);
|
||||
if (files.some((file) => file.type.startsWith("image/"))) {
|
||||
event.preventDefault();
|
||||
void insertImages(files);
|
||||
}
|
||||
};
|
||||
el.addEventListener("paste", onPaste);
|
||||
return () => el.removeEventListener("paste", onPaste);
|
||||
}, [disabled, insertImages]);
|
||||
|
||||
const tools = [
|
||||
{ label: "粗体", icon: <Bold size={15} />, run: () => surround("**", "**", "粗体") },
|
||||
{ label: "斜体", icon: <Italic size={15} />, run: () => surround("*", "*", "斜体") },
|
||||
{ label: "标题", icon: <span className="text-[13px] font-semibold">H</span>, run: () => prefixLines("## ") },
|
||||
{ label: "列表", icon: <ListIcon size={15} />, run: () => prefixLines("- ") },
|
||||
{ label: "引用", icon: <Quote size={15} />, run: () => prefixLines("> ") },
|
||||
{ label: "代码", icon: <Code size={15} />, run: () => surround("`", "`", "code") },
|
||||
{ label: "链接", icon: <Link2 size={15} />, run: () => surround("[", "](https://)", "链接文字") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={dragging ? "rounded-xl ring-2 ring-primary-500/40" : ""}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-line-100 pb-1.5">
|
||||
{tools.map((tool) => (
|
||||
<button
|
||||
key={tool.label}
|
||||
type="button"
|
||||
title={tool.label}
|
||||
aria-label={tool.label}
|
||||
disabled={disabled || preview}
|
||||
onClick={tool.run}
|
||||
className="grid h-7 w-7 place-items-center rounded-md text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
|
||||
>
|
||||
{tool.icon}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
title="插入图片"
|
||||
aria-label="插入图片"
|
||||
disabled={disabled || preview || uploading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="grid h-7 w-7 place-items-center rounded-md text-ink-500 transition hover:bg-paper-200 hover:text-ink-800 disabled:opacity-40"
|
||||
>
|
||||
<ImageIcon size={15} />
|
||||
</button>
|
||||
<span className="mx-1 h-4 w-px bg-line-100" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreview((prev) => !prev)}
|
||||
className="ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-2xs font-medium text-ink-500 transition hover:bg-paper-200 hover:text-ink-800"
|
||||
aria-pressed={preview}
|
||||
>
|
||||
{preview ? <Pencil size={13} /> : <Eye size={13} />}
|
||||
{preview ? "继续编辑" : "预览"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void insertImages(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{preview ? (
|
||||
<div className="min-h-[96px] rounded-lg border border-line-100 bg-paper-50 px-3 py-2.5">
|
||||
{value.trim() ? <Markdown source={value} /> : <p className="text-sm text-ink-400">还没有内容。</p>}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={ref}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={helpId}
|
||||
className="input min-h-[96px] resize-y border-0 bg-transparent px-0 py-2 font-mono text-[13px] leading-6 focus:ring-0"
|
||||
rows={rows}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onDragOver={(event) => {
|
||||
if (event.dataTransfer.types.includes("Files")) {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
const files = Array.from(event.dataTransfer.files ?? []);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void insertImages(files);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "b") {
|
||||
event.preventDefault();
|
||||
surround("**", "**", "粗体");
|
||||
} else if (key === "i") {
|
||||
event.preventDefault();
|
||||
surround("*", "*", "斜体");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p id={helpId} className="mt-1 text-2xs text-ink-400">
|
||||
{uploading ? "正在上传图片…" : "可直接粘贴或拖入图片;Ctrl+B 粗体、Ctrl+I 斜体"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,16 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "../App";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
|
||||
import type { Activity, Item, ItemDetail, Member, Page, Role, TicketInfo } from "../lib/types";
|
||||
import type {
|
||||
Activity,
|
||||
Attachment,
|
||||
Item,
|
||||
ItemDetail,
|
||||
Member,
|
||||
Page,
|
||||
Role,
|
||||
TicketInfo,
|
||||
} from "../lib/types";
|
||||
import {
|
||||
Book,
|
||||
Clock,
|
||||
@@ -14,6 +23,9 @@ import {
|
||||
Trash,
|
||||
Users,
|
||||
} from "./icons";
|
||||
import { AttachmentPanel } from "./AttachmentPanel";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { RichTextEditor } from "./RichTextEditor";
|
||||
import { Button } from "./ui";
|
||||
import { Sheet } from "./ui";
|
||||
|
||||
@@ -75,6 +87,7 @@ export function TaskDetail({
|
||||
const [tags, setTags] = useState("");
|
||||
const [due, setDue] = useState("");
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
@@ -94,6 +107,8 @@ export function TaskDetail({
|
||||
setBody(next.body_md);
|
||||
setTags(next.tags.map((t) => t.name).join(" "));
|
||||
setDue(toLocalInput(next.due_at));
|
||||
// 附件可能为空数组,兜一下:接口在某些分支(如 PATCH 回包)不带这个字段
|
||||
setAttachments(next.attachments ?? []);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -386,7 +401,9 @@ export function TaskDetail({
|
||||
{ticket.resolution_md ? (
|
||||
<div className="mt-3 rounded-lg border border-line-200 bg-white p-3">
|
||||
<p className="text-2xs text-ink-400">验收结论</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-ink-800">{ticket.resolution_md}</p>
|
||||
<div className="mt-1">
|
||||
<Markdown source={ticket.resolution_md} compact />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -471,17 +488,41 @@ export function TaskDetail({
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<div>
|
||||
<span className="label">备注</span>
|
||||
<textarea
|
||||
className="input min-h-32 resize-y leading-relaxed"
|
||||
aria-label="备注"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onBlur={() => void saveBody()}
|
||||
placeholder="背景、约束、链接…写下来,将来才想得起来"
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
|
||||
<RichTextEditor
|
||||
ariaLabel="备注"
|
||||
wsId={wsId}
|
||||
itemId={item.id}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
onError={(message) => setError(message)}
|
||||
rows={8}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{body !== item.body_md ? (
|
||||
<Button size="sm" variant="subtle" disabled={busy} onClick={() => void saveBody()}>
|
||||
保存备注
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-2xs text-ink-400">修改后自动提示保存</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
|
||||
<AttachmentPanel
|
||||
wsId={wsId}
|
||||
itemId={item.id}
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onError={(message) => setError(message)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{item.kind === "task" && !["done", "canceled"].includes(item.status) ? (
|
||||
<section className="rounded-xl border border-clay-200 bg-clay-50/50 p-4">
|
||||
@@ -565,7 +606,7 @@ export function TaskDetail({
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2">
|
||||
<p className="whitespace-pre-wrap text-sm text-ink-800">{c.body_md}</p>
|
||||
<Markdown source={c.body_md} />
|
||||
<p className="mt-0.5 text-2xs text-ink-400">
|
||||
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
|
||||
@@ -23,6 +23,69 @@ export const Check = (p: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Bold = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Italic = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M15 5h-5M14 19H9M14.5 5 10 19" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Quote = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M7 7h4v4c0 2.2-1.3 3.7-3.5 4.5M15 7h4v4c0 2.2-1.3 3.7-3.5 4.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Code = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="m9 8-4 4 4 4M15 8l4 4-4 4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ListIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Image = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<rect x="3.5" y="4.5" width="17" height="15" rx="2.5" />
|
||||
<circle cx="9" cy="10" r="1.6" />
|
||||
<path d="m4 17 4.5-4.5 3.5 3.5 3-3L20 17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Eye = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
|
||||
<circle cx="12" cy="12" r="2.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Pencil = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Paperclip = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M20 11.5 12.3 19a4.6 4.6 0 0 1-6.5-6.5l7.4-7.4a3.1 3.1 0 1 1 4.4 4.4l-7.4 7.4a1.5 1.5 0 1 1-2.2-2.2l6.6-6.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Download = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M4.5 19h15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CheckCircle = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
|
||||
@@ -162,3 +162,118 @@
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Markdown 渲染样式:不引 @tailwindcss/typography(它会把行距和字重改得不像我们的界面),
|
||||
这里只针对我们真正会渲染的标签写一遍,保持与组件库同一套色板与节奏。 */
|
||||
.prose-dadao > * + * {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
|
||||
.prose-dadao h1,
|
||||
.prose-dadao h2,
|
||||
.prose-dadao h3,
|
||||
.prose-dadao h4 {
|
||||
font-family: theme("fontFamily.display");
|
||||
color: theme("colors.ink.900");
|
||||
line-height: 1.35;
|
||||
margin-top: 1.1em;
|
||||
}
|
||||
.prose-dadao h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.prose-dadao h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.prose-dadao h3,
|
||||
.prose-dadao h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.prose-dadao strong {
|
||||
font-weight: 600;
|
||||
color: theme("colors.ink.900");
|
||||
}
|
||||
|
||||
.prose-dadao a {
|
||||
color: theme("colors.primary.700");
|
||||
text-decoration: underline;
|
||||
text-decoration-color: theme("colors.primary.300");
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.prose-dadao a:hover {
|
||||
color: theme("colors.primary.800");
|
||||
}
|
||||
|
||||
.prose-dadao ul,
|
||||
.prose-dadao ol {
|
||||
padding-left: 1.15rem;
|
||||
}
|
||||
.prose-dadao ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.prose-dadao ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.prose-dadao li::marker {
|
||||
color: theme("colors.ink.300");
|
||||
}
|
||||
.prose-dadao li + li {
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.prose-dadao blockquote {
|
||||
border-left: 2px solid theme("colors.primary.300");
|
||||
padding-left: 0.75rem;
|
||||
color: theme("colors.ink.600");
|
||||
}
|
||||
|
||||
.prose-dadao code {
|
||||
font-family: theme("fontFamily.mono");
|
||||
font-size: 0.86em;
|
||||
background: theme("colors.paper.200");
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
}
|
||||
|
||||
.prose-dadao pre {
|
||||
background: theme("colors.ink.900");
|
||||
color: theme("colors.paper.50");
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose-dadao pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.prose-dadao hr {
|
||||
border: 0;
|
||||
border-top: 1px solid theme("colors.line.200");
|
||||
}
|
||||
|
||||
.prose-dadao img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid theme("colors.line.100");
|
||||
}
|
||||
|
||||
.prose-dadao table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.prose-dadao th,
|
||||
.prose-dadao td {
|
||||
border: 1px solid theme("colors.line.200");
|
||||
padding: 0.35rem 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.prose-compact {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -104,3 +104,48 @@ export const api = {
|
||||
patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }),
|
||||
del: <T>(path: string) => raw<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传文件(multipart)。
|
||||
*
|
||||
* 不能用 api.post:它固定发 JSON。这里也不能自己设 Content-Type,
|
||||
* 必须让浏览器带 boundary,否则 FastAPI 解析不出文件。
|
||||
*/
|
||||
export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
const token = session.access;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
headers["Idempotency-Key"] = crypto.randomUUID();
|
||||
|
||||
const resp = await fetch(`${BASE}${path}`, { method: "POST", headers, body: form });
|
||||
|
||||
let body: { code: number; message: string; data: T; request_id: string } | null = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (!body) {
|
||||
throw new ApiError(resp.status, `上传失败:服务返回了非 JSON 响应(HTTP ${resp.status})`);
|
||||
}
|
||||
if (!resp.ok || body.code !== 0) {
|
||||
if (resp.status === 401) {
|
||||
session.clear();
|
||||
authExpired.dispatchEvent(new Event("expired"));
|
||||
}
|
||||
throw new ApiError(body.code, body.message, body.request_id);
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件直链:`<img>` 带不了 Authorization 头,所以令牌走 query。
|
||||
* 只在渲染 Markdown 图片时用,不要拿它去做普通的下载链接(那样 token 会留在浏览器历史里)。
|
||||
*/
|
||||
export function attachmentUrl(wsId: string, attachmentId: string): string {
|
||||
const token = session.access ?? "";
|
||||
return `${BASE}/workspaces/${wsId}/attachments/${attachmentId}/raw?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 极简 Markdown 渲染器。
|
||||
*
|
||||
* 为什么不用 react-markdown:我们的编辑场景只需要"能读懂的一小撮语法",
|
||||
* 引一个大依赖还得处理它的插件体系;而 XSS 这类问题,自己渲染反倒更容易
|
||||
* 把"先转义、再套标签"这条铁律钉死在一个文件里。
|
||||
*
|
||||
* 支持的语法刻意很小:标题、粗体/斜体/删除线、行内代码、代码块、有序/无序
|
||||
* 列表、引用、分隔线、链接、图片、(空行分隔的)段落。
|
||||
* 不支持的(表格、脚注、HTML 片段)会原样当文本显示 —— 显示不出来比显示一半安全。
|
||||
*/
|
||||
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]);
|
||||
}
|
||||
|
||||
/** 只放行 http/https/mailto 与站内相对地址,挡掉 javascript: 这类伪协议。 */
|
||||
function safeUrl(url: string): string | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^(https?:\/\/|mailto:|\/)/i.test(trimmed)) return trimmed;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 行内语法。先转义,再按"从最不可能被误伤"的顺序套标签。 */
|
||||
function inline(raw: string): string {
|
||||
let text = escapeHtml(raw);
|
||||
|
||||
// 行内代码先摘出来,避免里面的 * 和 _ 被当成强调
|
||||
const codes: string[] = [];
|
||||
text = text.replace(/`([^`]+)`/g, (_m, code: string) => {
|
||||
codes.push(code);
|
||||
return `\u0000CODE${codes.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
text = text
|
||||
//  与 [text](url)
|
||||
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt: string, url: string) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return match;
|
||||
return `<img src="${href}" alt="${alt}" loading="lazy" />`;
|
||||
})
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, label: string, url: string) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return match;
|
||||
const external = /^https?:\/\//i.test(href);
|
||||
const rel = external ? ' target="_blank" rel="noreferrer noopener"' : "";
|
||||
return `<a href="${href}"${rel}>${label}</a>`;
|
||||
})
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/(?:^|\s)__([^_]+)__(?=\s|$)/g, " <em>$1</em>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
|
||||
.replace(/(?:^|\s)_([^_]+)_(?=\s|$)/g, " <em>$1</em>")
|
||||
.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
||||
|
||||
// 还原行内代码(内容已在上面转义过,这里只负责套标签)
|
||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, idx: string) => `<code>${codes[Number(idx)]}</code>`);
|
||||
}
|
||||
|
||||
/** 渲染成可直接塞进 dangerouslySetInnerHTML 的字符串。 */
|
||||
export function renderMarkdown(source: string): string {
|
||||
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// 代码块:内容原样保留,只做转义,不做任何行内解析
|
||||
const fence = line.match(/^\s*```(\w*)\s*$/);
|
||||
if (fence) {
|
||||
const lang = fence[1];
|
||||
const body: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
|
||||
body.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
i += 1; // 跳过结束围栏(文件末尾没闭合也能渲染)
|
||||
const cls = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||
out.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*$/.test(line)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
|
||||
out.push("<hr />");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 引用:连续以 > 开头的行合并成一个 blockquote
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
|
||||
body.push(lines[i].replace(/^\s*>\s?/, ""));
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<blockquote>${inline(body.join(" "))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 列表:同类型的连续行归一组,嵌套只支持一层(够用,且不会渲染错)
|
||||
const bullet = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
||||
const ordered = line.match(/^(\s*)\d+[.)]\s+(.*)$/);
|
||||
if (bullet || ordered) {
|
||||
const isOrdered = Boolean(ordered);
|
||||
const items: string[] = [];
|
||||
const pattern = isOrdered ? /^(\s*)\d+[.)]\s+(.*)$/ : /^(\s*)[-*+]\s+(.*)$/;
|
||||
while (i < lines.length) {
|
||||
const m = lines[i].match(pattern);
|
||||
if (!m) break;
|
||||
const [, padding, content] = m;
|
||||
// 缩进两格以上视为子项,用一段加粗缩进表示(不生成嵌套 ol/ul,避免打错标签)
|
||||
items.push(
|
||||
padding.length >= 2
|
||||
? `<li class="ml-4">${inline(content)}</li>`
|
||||
: `<li>${inline(content)}</li>`,
|
||||
);
|
||||
i += 1;
|
||||
}
|
||||
const tag = isOrdered ? "ol" : "ul";
|
||||
out.push(`<${tag}>${items.join("")}</${tag}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 段落:吃到下一个空行或下一个块级语法为止
|
||||
const paragraph: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const current = lines[i];
|
||||
if (
|
||||
/^\s*$/.test(current) ||
|
||||
/^\s*```/.test(current) ||
|
||||
/^(#{1,4})\s+/.test(current) ||
|
||||
/^\s*>\s?/.test(current) ||
|
||||
/^\s*[-*+]\s+/.test(current) ||
|
||||
/^\s*\d+[.)]\s+/.test(current)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
paragraph.push(current);
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<p>${inline(paragraph.join("\n")).replace(/\n/g, "<br />")}</p>`);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** 纯文本摘要,用于列表页显示(去掉语法符号)。 */
|
||||
export function plainSummary(source: string, limit = 90): string {
|
||||
const text = source
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[#>*_~`-]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
||||
}
|
||||
@@ -81,7 +81,20 @@ export interface Activity {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
item_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
uploader_id: string;
|
||||
created_at: string;
|
||||
can_delete: boolean;
|
||||
}
|
||||
|
||||
export interface ItemDetail extends Item {
|
||||
attachments?: Attachment[];
|
||||
attachment_count?: number;
|
||||
activities: Activity[];
|
||||
ticket?: TicketInfo;
|
||||
}
|
||||
@@ -138,5 +151,7 @@ export interface KnowledgeSource {
|
||||
export interface Knowledge extends Item {
|
||||
knowledge: KnowledgeInfo;
|
||||
source: KnowledgeSource | null;
|
||||
attachments?: Attachment[];
|
||||
attachment_count?: number;
|
||||
activities?: Activity[];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { AttachmentPanel } from "../components/AttachmentPanel";
|
||||
import { RichTextEditor } from "../components/RichTextEditor";
|
||||
import { Book, Link2, Plus, Search, Sparkle } from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
StatusPill,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import type { Knowledge, KnowledgeStatus } from "../lib/types";
|
||||
import type { Attachment, Knowledge, KnowledgeStatus } from "../lib/types";
|
||||
|
||||
const TABS = [
|
||||
{ key: "draft", label: "待核对", statuses: ["draft", "in_review"] },
|
||||
@@ -299,6 +301,7 @@ function KnowledgeDrawer({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -307,6 +310,7 @@ function KnowledgeDrawer({
|
||||
setDoc(data);
|
||||
setTitle(data.title);
|
||||
setBody(data.body_md);
|
||||
setAttachments(data.attachments ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "加载失败");
|
||||
}
|
||||
@@ -447,13 +451,30 @@ function KnowledgeDrawer({
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
aria-label="标题"
|
||||
/>
|
||||
<textarea
|
||||
className="input min-h-[320px] resize-y font-mono text-xs leading-relaxed"
|
||||
value={body}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
aria-label="正文"
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2">
|
||||
<RichTextEditor
|
||||
ariaLabel="正文"
|
||||
wsId={wsId}
|
||||
itemId={doc.id}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
onError={(message) => setError(message)}
|
||||
rows={16}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line-200 bg-paper-50 px-3 py-2.5">
|
||||
<AttachmentPanel
|
||||
wsId={wsId}
|
||||
itemId={doc.id}
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onError={(message) => setError(message)}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <InlineAlert>{error}</InlineAlert>}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user