diff --git a/app/Http/Controllers/Api/DialogController.php b/app/Http/Controllers/Api/DialogController.php index 3dfddebfb..6dac8e8e0 100755 --- a/app/Http/Controllers/Api/DialogController.php +++ b/app/Http/Controllers/Api/DialogController.php @@ -2885,6 +2885,58 @@ class DialogController extends AbstractController return Base::retSuccess('success', $topMsg); } + /** + * @api {get} api/dialog/msg/applied 55. 标记消息已应用 + * + * @apiDescription 需要token身份 + * @apiVersion 1.0.0 + * @apiGroup dialog + * @apiName msg__applied + * + * @apiParam {String} type 类型 + * - CreateTask: 创建任务 + * @apiParam {Number} index 索引 + * @apiParam {Number} msg_id 消息ID + * + * @apiSuccess {Number} ret 返回状态码(1正确、0错误) + * @apiSuccess {String} msg 返回信息(错误描述) + * @apiSuccess {Object} data 返回数据 + */ + public function msg__applied() + { + User::auth(); + // + $msg_id = intval(Request::input('msg_id')); + $type = trim(Request::input('type')); + $index = intval(Request::input('index')); + // + $msg = WebSocketDialogMsg::whereId($msg_id)->first(); + if (empty($msg)) { + return Base::retError("消息不存在或已被删除"); + } + WebSocketDialog::checkDialog($msg->dialog_id); + // + $originalMsg = $msg->getRawOriginal('msg'); + $pattern = '/```\s*' . preg_quote($type, '/') . '\s*(applying|applied)?\s*(\n|\\\\n)/'; + $count = -1; + $updatedMsg = preg_replace_callback($pattern, function($matches) use (&$count, $index, $type) { + $count++; + if ($count === $index || ($index === 0 && $count === 1)) { + return "```{$type} applied{$matches[2]}"; + } + return $matches[0]; + }, $originalMsg); + + if ($count === 0) { + return Base::retError("未找到可应用的规则"); + } + + $msg->msg = $updatedMsg; + $msg->save(); + // + return Base::retSuccess("success"); + } + /** * @api {get} api/dialog/sticker/search 56. 搜索在线表情 * diff --git a/resources/assets/js/pages/manage/components/DialogWrapper.vue b/resources/assets/js/pages/manage/components/DialogWrapper.vue index 2c6a19a56..c76b63e02 100644 --- a/resources/assets/js/pages/manage/components/DialogWrapper.vue +++ b/resources/assets/js/pages/manage/components/DialogWrapper.vue @@ -3220,6 +3220,89 @@ export default { }); }, + async applyCreateTask(event, el) { + const currentTarget = event.target; + if (currentTarget.classList.contains('applying') || currentTarget.classList.contains('applied')) { + return; + } + currentTarget.classList.add('applying') + + if (this.dialogData.group_type !== 'project') { + currentTarget.classList.remove('applying') + $A.modalError('只有在项目中才能创建任务') + return + } + + let target = event.target; + while (target) { + if (target.classList.contains('apply-create-task')) { + break; + } + if (target.classList.contains('dialog-scroller')) { + target = null; + break; + } + target = target.parentElement; + } + if (!target) { + currentTarget.classList.remove('applying') + $A.modalError('未找到任务内容') + return + } + if (!this.dialogData.group_info) { + currentTarget.classList.remove('applying') + $A.modalError('项目不存在') + return; + } + + const allTaskElements = el.querySelectorAll('.apply-create-task'); + const taskIndex = Array.from(allTaskElements).indexOf(target); + const taskList = Array.from(target.querySelectorAll('li')) + .map(item => { + const title = item.querySelector('.title')?.innerText?.trim(); + if (!title) return null; + + const desc = item.querySelector('.desc')?.innerText?.trim() || ''; + const content = desc ? desc.split('\n') + .filter(Boolean) + .map(line => `

${line.trim()}

`) + .join('') : ''; + + return { + project_id: this.dialogData.group_info.id, + name: title, + content + }; + }) + .filter(Boolean); + + const results = await Promise.all(taskList.map(item => + this.$store.dispatch("taskAdd", item).then( + success => ({ success: true, data: success }), + error => ({ success: false, error: error }) + ) + )); + const successTasks = results.filter(r => r.success).map(r => r.data); + const failedTasks = results.filter(r => !r.success).map(r => r.error); + if (failedTasks.length > 0) { + $A.modalError(`成功创建 ${successTasks.length} 个任务,${failedTasks.length} 个任务创建失败`); + } else { + $A.messageSuccess(`成功创建 ${successTasks.length} 个任务`); + } + + currentTarget.classList.remove('applying') + currentTarget.classList.add('applied') + + await this.$store.dispatch("call", { + url: 'dialog/msg/applied', + data: { + type: 'CreateTask', + index: taskIndex, + msg_id: this.operateItem.id + }, + }); + }, + openTranslationMenu(event) { const list = Object.keys(languageList).map(item => ({ label: languageList[item], @@ -3325,6 +3408,13 @@ export default { } const {target, clientX} = event + // 创建任务 + if (target.classList.contains('apply-create-task-button')) { + this.operateItem = this.findMsgByElement(el) + this.applyCreateTask(event, el) + return; + } + // 点击切换翻译 if (target.classList.contains('translation-label')) { this.operateItem = this.findMsgByElement(el) diff --git a/resources/assets/js/store/markdown.js b/resources/assets/js/store/markdown.js index 3058df8cd..69f3a9065 100644 --- a/resources/assets/js/store/markdown.js +++ b/resources/assets/js/store/markdown.js @@ -9,6 +9,12 @@ import mdKatex from "@traptitech/markdown-it-katex"; const MarkdownUtils = { mdi: null, mds: null, + + /** + * 解析Markdown + * @param {*} text + * @returns + */ formatMsg: (text) => { const array = text.match(/]*?>/g); if (array) { @@ -18,11 +24,181 @@ const MarkdownUtils = { } return text }, + + /** + * 高亮代码块 + * @param {*} str + * @param {*} lang + * @returns + */ highlightBlock: (str, lang = '') => { return `
${lang}${$A.L('复制')}
${str}
` }, } +const MarkdownPluginUtils = { + // 配置选项 + config: { + maxTitleLength: 200, + maxDescLength: 1000, + maxItems: 200, + defaultTitle: '创建任务' + }, + + // HTML转义函数 + escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }, + + // 验证输入 + validateInput(value, maxLength) { + if (!value) return ''; + if (typeof value !== 'string') return ''; + if (value.length > maxLength) { + return value.substring(0, maxLength) + '...'; + } + return value; + }, + + // 解析任务项 + parseTaskItems(content) { + const items = []; + let currentItem = {}; + let itemCount = 0; + + content.forEach(line => { + line = line.trim(); + if (!line) { + if (Object.keys(currentItem).length > 0) { + items.push(currentItem); + currentItem = {}; + itemCount++; + } + return; + } + + if (itemCount >= this.config.maxItems) { + return; + } + + const [key, ...valueParts] = line.split(':'); + const value = valueParts.join(':').trim(); + + if (key === 'title' && value) { + if (Object.keys(currentItem).length > 0) { + items.push(currentItem); + currentItem = {}; + itemCount++; + } + currentItem.title = this.validateInput(value, this.config.maxTitleLength); + } else if (key === 'desc' && value) { + currentItem.desc = this.validateInput(value, this.config.maxDescLength); + } + }); + + if (Object.keys(currentItem).length > 0 && itemCount < this.config.maxItems) { + items.push(currentItem); + } + + return items; + }, + + // 生成HTML + generateTaskHtml(items, status = null) { + if (!Array.isArray(items) || items.length === 0) { + return ''; + } + + const html = [ + '
', + '', + `
${this.escapeHtml($A.L(this.config.defaultTitle))}
`, + '
' + ); + return html.join('\n'); + }, + + // 修改初始化插件函数 + initCreateTaskPlugin(md) { + md.block.ruler.before('fence', 'create_task', (state, startLine, endLine, silent) => { + try { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + + const match = state.src.slice(start, max).trim().match(/^```\s*CreateTask\s*(applying|applied)?$/); + if (!match) { + return false; + } + + if (silent) { + return true; + } + + let nextLine = startLine + 1; + let content = []; + let found = false; + + while (nextLine < endLine) { + const line = state.src.slice(state.bMarks[nextLine], state.eMarks[nextLine]).trim(); + if (line === '```') { + found = true; + break; + } + content.push(line); + nextLine++; + } + + if (!found) { + return false; + } + + // 创建 token 并设置为空字符串内容 + const token = state.push('html_block', '', 0); + + // 如果有内容,则解析并生成HTML + if (content.length > 0) { + const items = this.parseTaskItems(content); + const html = this.generateTaskHtml(items, match[1]); + token.content = html || ''; + } else { + token.content = ''; // 空内容直接返回空字符串 + } + + token.map = [startLine, nextLine + 1]; + state.line = nextLine + 1; + return true; + + } catch (error) { + console.error('Error in create_task parser:', error); + return false; + } + }); + } +}; + export function MarkdownConver(text) { if (text === '...') { return '' @@ -41,6 +217,7 @@ export function MarkdownConver(text) { }) MarkdownUtils.mdi.use(mila, {attrs: {target: '_blank', rel: 'noopener'}}) MarkdownUtils.mdi.use(mdKatex, {blockClass: 'katexmath-block rounded-md p-[10px]', errorColor: ' #cc0000'}) + MarkdownPluginUtils.initCreateTaskPlugin(MarkdownUtils.mdi); } return MarkdownUtils.formatMsg(MarkdownUtils.mdi.render(text)) } diff --git a/resources/assets/sass/pages/components/dialog-wrapper.scss b/resources/assets/sass/pages/components/dialog-wrapper.scss index 717a896f9..02b9fad6d 100644 --- a/resources/assets/sass/pages/components/dialog-wrapper.scss +++ b/resources/assets/sass/pages/components/dialog-wrapper.scss @@ -2037,6 +2037,97 @@ visibility: hidden; pointer-events: none; } + + .apply-create-task { + min-width: 160px; + margin-bottom: 16px; + + ul { + max-height: 500px; + overflow: auto; + padding: 0; + margin: 0; + list-style-type: none; + li { + display: flex; + margin-bottom: 12px; + .task-index { + padding-right: 6px; + } + .task-item { + line-height: 18px; + .title, + .desc { + word-break: break-all; + text-overflow: ellipsis; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .title { + font-weight: bold; + } + .desc { + padding-top: 4px; + opacity: 0.6; + } + } + } + } + + + .apply-button { + display: inline-block; + user-select: none; + > div { + display: flex; + justify-content: center; + align-items: center; + height: 32px; + line-height: 32px; + padding: 0 12px; + font-size: 14px; + border-radius: 4px; + color: #515a6e; + background-color: #fff; + border-color: #dcdee2; + cursor: pointer; + &:before { + font-family: "taskfont", "serif" !important; + content: "\e6f2"; + font-size: 14px; + width: 14px; + margin-right: 6px; + } + &.applying, + &.applied { + cursor: default; + } + &.applying { + &:before { + content: ""; + width: 14px; + height: 14px; + border: 2px solid #dddd; + border-bottom-color: $primary-color; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: pureing-rotation 0.75s linear infinite; + } + } + &.applied { + color: #c5c8ce; + background-color: #f7f7f7; + border-color: #dcdee2; + &:before { + content: "\e684"; + } + } + } + } + } } body:not(.window-touch) {