feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent ba91abc4b2
commit 83a16ed07d
225 files changed
+59337

No files matched your search

@@ -0,0 +1,156 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
// FaqCard → MarkdownEditor → faqService → api → router → views → firebase,
// which cannot initialize in the test environment.
vi.mock('@/services/firebase', () => ({
messaging: {},
requestNotificationPermission: vi.fn(),
}))
vi.mock('@/services/faq', () => ({
faqService: { uploadImage: vi.fn() },
}))
import FaqCard from '../../../components/faq/FaqCard.vue'
import MarkdownEditor from '../../../components/faq/MarkdownEditor.vue'
import type { FaqItem } from '../../../types/faq'
const faq: FaqItem = {
id: 'f1',
question: 'How does billing work?',
answer: 'Plans are billed per seat, monthly or yearly.',
category: 'Billing',
status: 'published',
knowledge_id: 1,
source_label: 'Billing & refunds policy',
}
const createWrapper = (props = {}) =>
mount(FaqCard, {
props: {
faq,
editing: false,
draftQuestion: '',
draftAnswer: '',
...props,
},
})
describe('FaqCard display mode', () => {
it('renders question, answer and source label', () => {
const wrapper = createWrapper()
expect(wrapper.find('.faq-card__question').text()).toBe('How does billing work?')
expect(wrapper.find('.faq-card__answer').text()).toBe('Plans are billed per seat, monthly or yearly.')
expect(wrapper.find('.faq-card__source').text()).toContain('Billing & refunds policy')
})
it('falls back to Generated when there is no source label', () => {
const wrapper = createWrapper({ faq: { ...faq, source_label: null } })
expect(wrapper.find('.faq-card__source').text()).toContain('自动生成')
})
it('shows the publish pill state', () => {
const published = createWrapper()
expect(published.find('.pill').classes()).toContain('pill--published')
expect(published.find('.pill').text()).toContain('已发布')
const draft = createWrapper({ faq: { ...faq, status: 'draft' } })
expect(draft.find('.pill').classes()).toContain('pill--draft')
expect(draft.find('.pill').text()).toContain('草稿')
})
it('emits toggle-status when the pill is clicked', async () => {
const wrapper = createWrapper()
await wrapper.find('.pill').trigger('click')
expect(wrapper.emitted('toggle-status')).toHaveLength(1)
})
it('emits edit and delete from the icon buttons', async () => {
const wrapper = createWrapper()
await wrapper.find('.icon-btn--edit').trigger('click')
await wrapper.find('.icon-btn--delete').trigger('click')
expect(wrapper.emitted('edit')).toHaveLength(1)
expect(wrapper.emitted('delete')).toHaveLength(1)
})
it('hides edit and delete and disables the pill when locked', () => {
const wrapper = createWrapper({ locked: true })
expect(wrapper.find('.icon-btn--edit').exists()).toBe(false)
expect(wrapper.find('.icon-btn--delete').exists()).toBe(false)
expect(wrapper.find('.pill').attributes('disabled')).toBeDefined()
})
})
describe('FaqCard edit mode', () => {
it('renders inputs bound to the drafts', () => {
const wrapper = createWrapper({
editing: true,
draftQuestion: 'Draft question?',
draftAnswer: 'Draft answer.',
})
expect((wrapper.find('.edit-question').element as HTMLInputElement).value).toBe('Draft question?')
// The answer is edited through the MarkdownEditor component.
expect(wrapper.findComponent(MarkdownEditor).props('modelValue')).toBe('Draft answer.')
expect(wrapper.find('.edit-label').text()).toBe('编辑中')
})
it('labels a new FAQ', () => {
const wrapper = createWrapper({ editing: true, isNew: true })
expect(wrapper.find('.edit-label').text()).toBe('新建常见问题')
})
it('emits draft updates on input', async () => {
const wrapper = createWrapper({ editing: true })
await wrapper.find('.edit-question').setValue('New question')
wrapper.findComponent(MarkdownEditor).vm.$emit('update:modelValue', 'New answer')
expect(wrapper.emitted('update:draftQuestion')?.[0]).toEqual(['New question'])
expect(wrapper.emitted('update:draftAnswer')?.[0]).toEqual(['New answer'])
})
it('renders the topic input with existing topics as autocomplete options', () => {
const wrapper = createWrapper({
editing: true,
draftCategory: 'Billing',
categories: ['Billing', 'Getting started'],
})
expect((wrapper.find('.edit-topic__input').element as HTMLInputElement).value).toBe('Billing')
const options = wrapper.findAll('#faq-topic-options option')
expect(options.map((o) => o.attributes('value'))).toEqual(['Billing', 'Getting started'])
})
it('emits topic draft updates on input', async () => {
const wrapper = createWrapper({ editing: true })
await wrapper.find('.edit-topic__input').setValue('Refunds')
expect(wrapper.emitted('update:draftCategory')?.[0]).toEqual(['Refunds'])
})
it('emits save and cancel', async () => {
const wrapper = createWrapper({ editing: true })
await wrapper.find('.btn-save').trigger('click')
await wrapper.find('.btn-cancel').trigger('click')
expect(wrapper.emitted('save')).toHaveLength(1)
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('disables Save while saving', () => {
const wrapper = createWrapper({ editing: true, saving: true })
expect(wrapper.find('.btn-save').attributes('disabled')).toBeDefined()
expect(wrapper.find('.btn-save').text()).toBe('保存中…')
})
})
@@ -0,0 +1,77 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FaqGenerateBar from '../../../components/faq/FaqGenerateBar.vue'
const createWrapper = (props = {}) =>
mount(FaqGenerateBar, {
props: {
phase: 'idle' as const,
sourceCount: 5,
pageCount: 9,
faqCount: 12,
publishedCount: 7,
...props,
},
})
describe('FaqGenerateBar', () => {
it('shows knowledge base counts and Generate when idle', () => {
const wrapper = createWrapper()
expect(wrapper.find('.generate-bar__title').text()).toBe('生成常见问题')
expect(wrapper.find('.generate-bar__sub').text()).toContain('读取 5 个来源 · 9 个页面')
expect(wrapper.find('.btn--generate').text()).toContain('生成')
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeUndefined()
})
it('shows FAQ counts and Regenerate when ready', () => {
const wrapper = createWrapper({ phase: 'ready' })
expect(wrapper.find('.generate-bar__title').text()).toBe('常见问题待发布')
expect(wrapper.find('.generate-bar__sub').text()).toContain('12 条常见问题 · 7 条已发布')
expect(wrapper.find('.btn--generate').text()).toContain('重新生成')
})
it('disables the generate button while generating', () => {
const wrapper = createWrapper({ phase: 'generating' })
expect(wrapper.find('.generate-bar__title').text()).toBe('正在生成常见问题…')
expect(wrapper.find('.btn--generate').text()).toContain('生成中…')
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeDefined()
})
it('respects the disabled prop', () => {
const wrapper = createWrapper({ disabled: true })
expect(wrapper.find('.btn--generate').attributes('disabled')).toBeDefined()
})
it('emits generate, import and add', async () => {
const wrapper = createWrapper()
await wrapper.find('.btn--generate').trigger('click')
const ghostButtons = wrapper.findAll('.btn--ghost')
await ghostButtons[0].trigger('click')
await ghostButtons[1].trigger('click')
expect(wrapper.emitted('generate')).toHaveLength(1)
expect(wrapper.emitted('import')).toHaveLength(1)
expect(wrapper.emitted('add')).toHaveLength(1)
})
it('does not emit generate when disabled', async () => {
const wrapper = createWrapper({ phase: 'generating' })
await wrapper.find('.btn--generate').trigger('click')
expect(wrapper.emitted('generate')).toBeUndefined()
})
})
@@ -0,0 +1,74 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FaqGenerationProgress from '../../../components/faq/FaqGenerationProgress.vue'
import type { FaqGenerationJob, FaqJobStage } from '../../../types/faq'
const job = (stage: FaqJobStage): FaqGenerationJob => ({
id: 1,
job_type: 'generate_all',
status: 'processing',
stage,
progress_percentage: 40,
faqs_created: 0,
source_url: null,
error: null,
})
const stepStates = (stage: FaqJobStage) => {
const wrapper = mount(FaqGenerationProgress, { props: { job: job(stage) } })
return wrapper.findAll('.step').map((step) => {
if (step.classes().includes('done')) return 'done'
if (step.classes().includes('active')) return 'active'
return 'todo'
})
}
describe('FaqGenerationProgress', () => {
it('renders four steps with the expected labels', () => {
const wrapper = mount(FaqGenerationProgress, { props: { job: job('analyzing_sources') } })
const labels = wrapper.findAll('.step__label').map((el) => el.text())
expect(labels).toEqual([
'分析你的来源',
'从内容中提取问题',
'撰写有依据的回答',
'按分类整理',
])
})
it('marks earlier stages done, the current stage active, later stages todo', () => {
expect(stepStates('drafting')).toEqual(['done', 'done', 'active', 'todo'])
})
it('starts with the first stage active', () => {
expect(stepStates('analyzing_sources')).toEqual(['active', 'todo', 'todo', 'todo'])
expect(stepStates('not_started')).toEqual(['active', 'todo', 'todo', 'todo'])
})
it('marks everything done when completed', () => {
expect(stepStates('completed')).toEqual(['done', 'done', 'done', 'done'])
})
it('shows pulsing dots only on the active step', () => {
const wrapper = mount(FaqGenerationProgress, { props: { job: job('drafting') } })
const steps = wrapper.findAll('.step')
expect(steps[2].find('.step__pulse').exists()).toBe(true)
expect(steps[0].find('.step__pulse').exists()).toBe(false)
expect(steps[3].find('.step__pulse').exists()).toBe(false)
})
})
@@ -0,0 +1,97 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import FaqImportModal from '../../../components/faq/FaqImportModal.vue'
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
const createWrapper = () =>
mount(FaqImportModal, {
props: { open: true },
global: {
stubs: {
Modal: { template: '<div><slot name="title" /><slot name="content" /></div>' },
FaqOrb: true,
},
},
})
/** Pick an import mode by its radio value. */
const chooseMode = (wrapper: ReturnType<typeof createWrapper>, mode: string) =>
wrapper.find(`input[value="${mode}"]`).setValue(true)
describe('FaqImportModal', () => {
it('offers the preserve-URLs option only for an article migration', async () => {
const wrapper = createWrapper()
// Q&A and PDF modes extract answers from one document — there are no
// per-article source URLs to preserve.
expect(wrapper.find('.preserve').exists()).toBe(false)
await chooseMode(wrapper, 'articles')
expect(wrapper.find('.preserve').exists()).toBe(true)
await chooseMode(wrapper, 'pdf')
expect(wrapper.find('.preserve').exists()).toBe(false)
})
it('preserves URLs by default, since that is what a migration wants', async () => {
const wrapper = createWrapper()
await chooseMode(wrapper, 'articles')
await wrapper.find('#faq-import-url').setValue('support.acme.com/help')
await wrapper.find('.btn-import').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual([
'https://support.acme.com/help',
'articles',
true,
])
})
it('submits the opt-out when the box is unticked', async () => {
const wrapper = createWrapper()
await chooseMode(wrapper, 'articles')
await wrapper.find('#faq-import-url').setValue('support.acme.com/help')
await wrapper.find('.preserve__box').setValue(false)
await wrapper.find('.btn-import').trigger('click')
expect(wrapper.emitted('submit')?.[0]?.[2]).toBe(false)
})
it('never claims to preserve URLs in a mode that has none', async () => {
const wrapper = createWrapper()
await wrapper.find('#faq-import-url').setValue('support.acme.com/faq')
await wrapper.find('.btn-import').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual(['https://support.acme.com/faq', 'qa', false])
})
it('resets the choice when the mode changes, so it cannot go stale', async () => {
const wrapper = createWrapper()
await chooseMode(wrapper, 'articles')
await wrapper.find('.preserve__box').setValue(false)
await chooseMode(wrapper, 'qa')
await chooseMode(wrapper, 'articles')
expect((wrapper.find('.preserve__box').element as HTMLInputElement).checked).toBe(true)
})
})
@@ -0,0 +1,90 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import FaqSeoFields from '../../../components/faq/FaqSeoFields.vue'
const createWrapper = (props = {}) => mount(FaqSeoFields, { props })
// The meta-title input is the only plain .seo__input without a modifier class.
const titleInput = (wrapper: ReturnType<typeof createWrapper>) =>
wrapper.findAll('input.seo__input').filter((i) => !i.classes().some((c) => c.includes('--')))[0]
describe('FaqSeoFields', () => {
it('stays collapsed until opened when nothing is customised', async () => {
const wrapper = createWrapper()
expect(wrapper.find('.seo__fields').exists()).toBe(false)
await wrapper.find('.seo__toggle').trigger('click')
expect(wrapper.find('.seo__fields').exists()).toBe(true)
})
it('opens itself when the article already has overrides', () => {
// A customised value must never be hidden behind a collapsed panel.
const wrapper = createWrapper({ metaTitle: 'Custom title' })
expect(wrapper.find('.seo__fields').exists()).toBe(true)
expect(wrapper.find('.seo__badge').exists()).toBe(true)
})
it('can still be collapsed after opening itself', async () => {
const wrapper = createWrapper({ metaTitle: 'Custom title' })
await wrapper.find('.seo__toggle').trigger('click')
expect(wrapper.find('.seo__fields').exists()).toBe(false)
})
it('does not treat a slug alone as customised', () => {
// Every article gets a generated slug, so its presence says nothing about
// whether the org edited anything.
const wrapper = createWrapper({ slug: 'auto-generated-slug' })
expect(wrapper.find('.seo__badge').exists()).toBe(false)
})
it('emits updates for each field', async () => {
const wrapper = createWrapper({ metaTitle: 'x' })
await wrapper.find('.seo__input--slug').setValue('custom-slug')
await wrapper.find('.seo__input--path').setValue('/hc/articles/1')
await titleInput(wrapper).setValue('New title')
await wrapper.find('textarea').setValue('New description')
expect(wrapper.emitted('update:slug')?.[0]).toEqual(['custom-slug'])
expect(wrapper.emitted('update:urlPath')?.[0]).toEqual(['/hc/articles/1'])
expect(wrapper.emitted('update:metaTitle')?.at(-1)).toEqual(['New title'])
expect(wrapper.emitted('update:metaDescription')?.[0]).toEqual(['New description'])
})
it('treats a preserved URL path as a customisation', () => {
// A migrated article's URL must not be hidden behind a collapsed panel.
const wrapper = createWrapper({ urlPath: '/hc/en-us/articles/360012-reset' })
expect(wrapper.find('.seo__fields').exists()).toBe(true)
expect(wrapper.find('.seo__badge').exists()).toBe(true)
expect((wrapper.find('.seo__input--path').element as HTMLInputElement).value).toBe(
'/hc/en-us/articles/360012-reset',
)
})
it('flags a title past the length search engines truncate at', () => {
const wrapper = createWrapper({ metaTitle: 'a'.repeat(61) })
expect(wrapper.find('.seo__count--over').exists()).toBe(true)
})
it('shows the question as the title placeholder so the default is visible', () => {
const wrapper = createWrapper({ metaTitle: 'x', question: 'How do I sign up?' })
expect(titleInput(wrapper).attributes('placeholder')).toBe('How do I sign up?')
})
})
@@ -0,0 +1,53 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { toast } from 'vue-sonner'
import CopyField from '../../../components/integrations/CopyField.vue'
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
const mountField = () => mount(CopyField, {
props: { fieldId: 'verify-token', label: 'Verify token', name: 'Verify token', value: 'tok-123' },
})
describe('CopyField', () => {
beforeEach(() => vi.clearAllMocks())
it('labels its input', () => {
const wrapper = mountField()
expect(wrapper.find('label').attributes('for')).toBe('verify-token')
expect(wrapper.find('input').attributes('id')).toBe('verify-token')
})
it('copies the value', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })
await mountField().find('button').trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith('tok-123')
expect(toast.success).toHaveBeenCalledWith('Verify token已复制')
})
it('says so when the browser will not copy', async () => {
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('insecure')) } })
await mountField().find('button').trigger('click')
await flushPromises()
expect(toast.error).toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MessengerPagePicker from '../../../components/integrations/MessengerPagePicker.vue'
const PAGES = [
{ id: 'PAGE1', name: 'Acme Support' },
{ id: 'PAGE2', name: 'Acme Sales' },
]
const mountPicker = (connecting = false) =>
mount(MessengerPagePicker, {
props: { pages: PAGES, connecting },
global: { stubs: { 'font-awesome-icon': true } },
})
describe('MessengerPagePicker', () => {
it('renders one row per Page', () => {
const wrapper = mountPicker()
expect(wrapper.findAll('.picker-row')).toHaveLength(2)
expect(wrapper.text()).toContain('Acme Support')
expect(wrapper.text()).toContain('Acme Sales')
})
it('emits select with the picked Page id', async () => {
const wrapper = mountPicker()
await wrapper.findAll('.picker-row')[1].trigger('click')
expect(wrapper.emitted('select')).toEqual([['PAGE2']])
})
it('locks every row while a connect is in flight', () => {
const wrapper = mountPicker(true)
const rows = wrapper.findAll('.picker-row')
expect(rows.every((row) => row.attributes('disabled') !== undefined)).toBe(true)
})
})
@@ -0,0 +1,78 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import MetaChannelConnect from '../../../components/integrations/MetaChannelConnect.vue'
import channelsService from '../../../services/channels'
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
vi.mock('../../../services/channels', () => ({
default: { connectWhatsApp: vi.fn(), connectMessenger: vi.fn(), connectInstagram: vi.fn() },
}))
vi.mock('../../../services/agent', () => ({ agentService: { getOrganizationAgents: vi.fn().mockResolvedValue([]) } }))
// The manual form, as when Meta's one-click signup isn't offered
vi.mock('../../../composables/useMetaSignup', () => ({
useMetaSignup: () => ({
signupEnabled: ref(false), signingUp: ref(false), showManualForm: ref(true), signupPages: ref([]),
connectingPage: ref(false), copy: { intro: '', cta: '' }, startSignup: vi.fn(), onPageSelected: vi.fn(),
}),
}))
const OWN_APP_ACCOUNT = {
id: 'acc-1', channel_type: 'whatsapp', external_account_id: 'PN9', is_active: true, display_name: 'Acme',
webhook_url: 'https://api.example.com/api/v1/webhooks/meta/acc-1', webhook_verify_token: 'tok', webhook_auto_configured: false,
}
const fill = async (wrapper: ReturnType<typeof mount>, values: Record<string, string>) => {
for (const [key, value] of Object.entries(values)) {
await wrapper.find(`#meta-${key}`).setValue(value)
}
await wrapper.findAll('button').find(b => b.text() === '连接')!.trigger('click')
await flushPromises()
}
describe('MetaChannelConnect app secret', () => {
beforeEach(() => vi.clearAllMocks())
it('connects without a secret and sends none', async () => {
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue({ ...OWN_APP_ACCOUNT, webhook_url: undefined } as any)
const wrapper = mount(MetaChannelConnect, { props: { channel: 'whatsapp' }, global: { stubs: { 'font-awesome-icon': true } } })
await fill(wrapper, { phone_number_id: 'PN9', access_token: 'EAAG-x' })
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith({ phone_number_id: 'PN9', access_token: 'EAAG-x' })
expect(wrapper.text()).not.toContain('回调 URL')
})
it('sends the secret and shows where webhooks go', async () => {
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue(OWN_APP_ACCOUNT as any)
const wrapper = mount(MetaChannelConnect, { props: { channel: 'whatsapp' }, global: { stubs: { 'font-awesome-icon': true } } })
await fill(wrapper, { phone_number_id: 'PN9', access_token: 'EAAG-x', app_secret: ' s3cret ' })
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith(
{ phone_number_id: 'PN9', access_token: 'EAAG-x', app_secret: 's3cret' })
expect(wrapper.text()).toContain('回调 URL')
expect(wrapper.text()).toContain('WhatsApp → 配置')
})
it('offers the secret on Messenger too, not Instagram', () => {
const has = (channel: 'messenger' | 'instagram') =>
mount(MetaChannelConnect, { props: { channel }, global: { stubs: { 'font-awesome-icon': true } } })
.find('#meta-app_secret').exists()
expect(has('messenger')).toBe(true)
expect(has('instagram')).toBe(false)
})
})
@@ -0,0 +1,80 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/services/agent', () => ({
agentService: { getOrganizationAgents: vi.fn().mockResolvedValue([{ id: 'ag1', name: 'Bot' }]) },
}))
vi.mock('@/services/channels', () => ({
default: {
getEmbeddedSignupConfig: vi.fn().mockResolvedValue({ enabled: false }),
connectWhatsApp: vi.fn(),
},
}))
import channelsService from '@/services/channels'
import MetaChannelConnect from '../../../components/integrations/MetaChannelConnect.vue'
import type { ChannelAccount } from '@/services/channels'
const ACCOUNT: ChannelAccount = {
id: 'acc1', channel_type: 'whatsapp', external_account_id: 'PN1',
display_name: 'Test Number', is_active: true, agent_id: 'ag1',
}
const mountModal = (existingAccount: ChannelAccount) =>
mount(MetaChannelConnect, {
props: { channel: 'whatsapp', existingAccount },
global: { stubs: { 'font-awesome-icon': true } },
})
describe('MetaChannelConnect reconnect prompt', () => {
beforeEach(() => vi.clearAllMocks())
it('does not offer a reconnect for a healthy account', async () => {
const wrapper = mountModal(ACCOUNT)
await flushPromises()
expect(wrapper.find('.meta-reconnect').exists()).toBe(false)
expect(wrapper.text()).toContain('已连接')
})
it('offers a reconnect when Meta stopped accepting the account', async () => {
const wrapper = mountModal({ ...ACCOUNT, needs_reconnect: true })
await flushPromises()
expect(wrapper.find('.meta-reconnect').text()).toContain('请重新连接以恢复回复')
})
it('Reconnect opens the credentials form, and connecting again clears the prompt', async () => {
vi.mocked(channelsService.connectWhatsApp).mockResolvedValue({ ...ACCOUNT, needs_reconnect: false })
const wrapper = mountModal({ ...ACCOUNT, needs_reconnect: true })
await flushPromises()
await wrapper.find('.meta-reconnect button').trigger('click')
expect(wrapper.find('#meta-phone_number_id').exists()).toBe(true)
await wrapper.find('#meta-phone_number_id').setValue('PN1')
await wrapper.find('#meta-access_token').setValue('new-token')
await wrapper.findAll('.meta-actions .meta-btn-primary').at(-1)!.trigger('click')
await flushPromises()
expect(channelsService.connectWhatsApp).toHaveBeenCalledWith(
expect.objectContaining({ phone_number_id: 'PN1', access_token: 'new-token' }))
expect(wrapper.find('.meta-reconnect').exists()).toBe(false)
expect(wrapper.text()).toContain('已连接')
})
})
@@ -0,0 +1,52 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import MetaOwnAppWebhook from '../../../components/integrations/MetaOwnAppWebhook.vue'
import type { ChannelAccount } from '../../../services/channels'
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
const account = (channel_type: 'whatsapp' | 'messenger', webhook_auto_configured?: boolean): ChannelAccount => ({
id: 'acc-1',
channel_type,
external_account_id: 'PN1',
is_active: true,
webhook_url: 'https://api.example.com/api/v1/webhooks/meta/acc-1',
webhook_verify_token: 'tok-123',
webhook_auto_configured,
})
const text = (a: ChannelAccount) => mount(MetaOwnAppWebhook, { props: { account: a } }).text()
describe('MetaOwnAppWebhook', () => {
it('shows the callback URL and verify token', () => {
const inputs = mount(MetaOwnAppWebhook, { props: { account: account('whatsapp', true) } }).findAll('input')
expect(inputs.map(i => (i.element as HTMLInputElement).value)).toEqual([
'https://api.example.com/api/v1/webhooks/meta/acc-1', 'tok-123'])
})
it('says a WhatsApp number is set up only when Meta took the callback', () => {
expect(text(account('whatsapp', true))).toContain('无需额外配置')
expect(text(account('whatsapp', false))).toContain('WhatsApp → 配置')
})
it('tells Messenger where the values go', () => {
expect(text(account('messenger'))).toContain('Messenger → 设置 → Webhooks')
})
})
@@ -0,0 +1,89 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import BottomNav from '@/components/layout/BottomNav.vue'
// Nav visibility comes from the real permission checks reading the cached
// user, so give this spec a user who holds everything.
vi.mock('@/services/user', async () => {
const { userWithPermissions } = await import('../../fixtures/permissions')
const user = userWithPermissions([
'manage_agents', 'manage_users', 'view_all_chats', 'manage_all_chats', 'view_people',
'manage_knowledge', 'view_analytics', 'view_tickets', 'manage_organization',
'manage_ai_config', 'manage_subscription',
])
return { userService: { getCurrentUser: () => user } }
})
vi.mock('@/composables/useEnterpriseFeatures', () => ({
useEnterpriseFeatures: () => ({ hasEnterpriseModule: false }),
}))
const routes = ['/conversations', '/people', '/ai-agents', '/analytics', '/knowledge'].map(
(path) => ({ path, component: { template: '<div />' } })
)
routes.push({ path: '/', component: { template: '<div />' } })
const makeRouter = () => createRouter({ history: createWebHistory(), routes })
const mountNav = async (props = {}, path = '/conversations') => {
const router = makeRouter()
await router.push(path)
await router.isReady()
return mount(BottomNav, { props, global: { plugins: [router] } })
}
describe('BottomNav', () => {
it('renders the four primary items plus More', async () => {
const wrapper = await mountNav()
const labels = wrapper.findAll('.bottom-nav-label').map((n) => n.text())
expect(labels).toEqual(['会话', '联系人', 'AI 客服', '数据分析', '更多'])
})
it('marks the current route active', async () => {
const wrapper = await mountNav({}, '/analytics')
const active = wrapper.findAll('.bottom-nav-item.active')
expect(active).toHaveLength(1)
expect(active[0].text()).toContain('数据分析')
})
it('marks More active when on an overflow route', async () => {
const wrapper = await mountNav({}, '/knowledge')
const active = wrapper.findAll('.bottom-nav-item.active')
expect(active).toHaveLength(1)
expect(active[0].text()).toContain('更多')
})
it('shows an unread badge on More and caps it at 99+', async () => {
const wrapper = await mountNav({ unreadCount: 3 })
expect(wrapper.find('.bottom-nav-badge').text()).toBe('3')
await wrapper.setProps({ unreadCount: 120 })
expect(wrapper.find('.bottom-nav-badge').text()).toBe('99+')
await wrapper.setProps({ unreadCount: 0 })
expect(wrapper.find('.bottom-nav-badge').exists()).toBe(false)
})
it('emits more when the More button is tapped', async () => {
const wrapper = await mountNav()
await wrapper.find('button.bottom-nav-item').trigger('click')
expect(wrapper.emitted('more')).toHaveLength(1)
})
})
@@ -0,0 +1,177 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Drive the REAL permission checks from a cached user, rather than mocking
// permissionChecks. Nav visibility and the router now read the same map, and a
// mock of the checks cannot catch the two disagreeing — which is exactly the
// bug this suite missed: People was listed for anyone with a chat grant while
// the route required view_people, so the link bounced.
const currentUser = await vi.hoisted(async () => ({ value: null as unknown }))
vi.mock('@/services/user', () => ({
userService: { getCurrentUser: () => currentUser.value },
}))
vi.mock('@/composables/useEnterpriseFeatures', () => ({
useEnterpriseFeatures: () => ({ hasEnterpriseModule: false }),
}))
import { useNavItems, PRIMARY_NAV_PATHS } from '@/components/layout/navItems'
import { userWithPermissions, HUMAN_AGENT_PERMISSIONS } from '../../fixtures/permissions'
const ALL_PERMISSIONS = [
'manage_agents', 'view_agents', 'manage_users', 'view_all_chats', 'manage_all_chats',
'view_assigned_chats', 'manage_assigned_chats', 'view_unassigned_chats', 'view_people',
'manage_knowledge', 'view_knowledge', 'view_analytics', 'view_tickets', 'manage_tickets',
'manage_organization', 'view_organization', 'manage_ai_config', 'view_ai_config',
'manage_subscription', 'view_subscription',
]
const asUser = (permissions: string[]) => {
currentUser.value = userWithPermissions(permissions)
}
describe('useNavItems', () => {
beforeEach(() => asUser(ALL_PERMISSIONS))
it('splits primary (bottom-nav) and overflow (More sheet) items', () => {
const { primaryNavItems, moreNavItems } = useNavItems()
expect(primaryNavItems.value.map((i) => i.to)).toEqual([
'/conversations',
'/people',
'/ai-agents',
'/analytics',
])
const morePaths = moreNavItems.value.map((i) => i.to)
expect(morePaths).toContain('/human-agents')
expect(morePaths).toContain('/knowledge')
expect(morePaths).toContain('/settings/user')
morePaths.forEach((p) => expect(PRIMARY_NAV_PATHS).not.toContain(p))
moreNavItems.value.forEach((i) => expect(i.section).toBeUndefined())
})
it('hides permission-gated items for restricted users', () => {
asUser(['manage_agents'])
const { primaryNavItems, moreNavItems, navItems } = useNavItems()
const primaryPaths = primaryNavItems.value.map((i) => i.to)
expect(primaryPaths).not.toContain('/conversations')
expect(primaryPaths).not.toContain('/people')
expect(primaryPaths).not.toContain('/analytics')
expect(primaryPaths).toContain('/ai-agents')
expect(moreNavItems.value.map((i) => i.to)).not.toContain('/human-agents')
// User settings is always available
expect(navItems.value.map((i) => i.to)).toContain('/settings/user')
})
// The product requirement, as a test: a Human Agent sees the inbox, the
// people they talk to, and their own settings. Nothing else.
it('shows a Human Agent exactly Inbox, People and User Settings', () => {
asUser(HUMAN_AGENT_PERMISSIONS)
const { navItems } = useNavItems()
expect(navItems.value.filter((i) => i.to).map((i) => i.to)).toEqual([
'/conversations',
'/people',
'/settings/user',
])
})
// A role with only view_people gets the People link; one with only a chat
// grant does not. The nav used to gate People on chat permissions while the
// route required view_people, so both cases were wrong in opposite ways.
it('gates People on the same permissions as its route', () => {
asUser(['view_people'])
expect(useNavItems().navItems.value.map((i) => i.to)).toContain('/people')
asUser(['view_assigned_chats'])
expect(useNavItems().navItems.value.map((i) => i.to)).toContain('/people')
asUser(['manage_knowledge'])
expect(useNavItems().navItems.value.map((i) => i.to)).not.toContain('/people')
})
// super_admin bypasses every check on the backend; a frontend check that
// does not bypass gives that role an almost-empty sidebar.
it('shows everything to a super_admin', () => {
asUser(['super_admin'])
const paths = useNavItems().navItems.value.filter((i) => i.to).map((i) => i.to)
expect(paths).toContain('/ai-agents')
expect(paths).toContain('/human-agents')
expect(paths).toContain('/knowledge')
expect(paths).toContain('/settings/organization')
})
it('excludes enterprise subscription when enterprise module is absent', () => {
const { moreNavItems } = useNavItems()
expect(moreNavItems.value.map((i) => i.to)).not.toContain('/settings/subscription')
})
// Guards the whole point of the More sheet: mobile must expose exactly the
// same destinations as the desktop sidebar, so adding a nav item can never
// silently leave phones without it.
it('surfaces every sidebar destination across the bottom nav and More sheet', () => {
const { navItems, primaryNavItems, moreNavGroups } = useNavItems()
const sidebarPaths = navItems.value.filter((i) => i.to).map((i) => i.to)
const mobilePaths = [
...primaryNavItems.value.map((i) => i.to),
...moreNavGroups.value.flatMap((g) => g.items.map((i) => i.to)),
]
expect(mobilePaths.slice().sort()).toEqual(sidebarPaths.slice().sort())
})
it('keeps the sidebar section headings in the More sheet', () => {
const { moreNavGroups } = useNavItems()
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['主菜单', '设置'])
// No empty groups — a section fully covered by the bottom nav is dropped
moreNavGroups.value.forEach((g) => expect(g.items.length).toBeGreaterThan(0))
})
// User Settings is always visible while the Settings heading used to be
// permission-gated separately, which orphaned the item into Main Menu.
it('keeps always-visible settings items under Settings for restricted users', () => {
// Knowledge, so Main Menu still has a non-primary item to group; no
// settings grants, so Settings holds only the always-visible entry.
asUser(['manage_knowledge'])
const { moreNavGroups } = useNavItems()
const settings = moreNavGroups.value.find((g) => g.section === '设置')
expect(settings?.items.map((i) => i.to)).toEqual(['/settings/user'])
expect(moreNavGroups.value.find((g) => g.section === '主菜单')?.items.map((i) => i.to))
.not.toContain('/settings/user')
})
it('drops a section whose every item is permission-hidden', () => {
asUser([])
const { moreNavGroups, navItems } = useNavItems()
// Only User Settings survives, so Main Menu disappears entirely
expect(moreNavGroups.value.map((g) => g.section)).toEqual(['设置'])
expect(navItems.value.filter((i) => i.section).map((i) => i.section)).toEqual(['设置'])
})
})
@@ -0,0 +1,98 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import InvestigationNoteForm from '@/components/tickets/InvestigationNoteForm.vue'
// attachTo puts the component in the real document so focus can be asserted,
// and VTU only takes it back out on unmount — without this the forms pile up
// and whichever mounted last owns document.activeElement.
const mounted: VueWrapper[] = []
afterEach(() => {
while (mounted.length) mounted.pop()!.unmount()
})
const mountForm = (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => {
const wrapper = mount(InvestigationNoteForm, {
props: { placeholder: 'Why?', submitLabel: 'Go', ...props },
slots,
attachTo: document.body,
})
mounted.push(wrapper)
return wrapper
}
describe('InvestigationNoteForm', () => {
it('trims the note before emitting', async () => {
const wrapper = mountForm()
await wrapper.find('textarea').setValue(' use fields.order_ref ')
await wrapper.find('.submit-btn').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual(['use fields.order_ref'])
})
it('allows an empty note', async () => {
// Re-running unchanged is legitimate, and the reject flow has always
// permitted a reason-less rejection.
const wrapper = mountForm()
await wrapper.find('.submit-btn').trigger('click')
expect(wrapper.emitted('submit')?.[0]).toEqual([''])
})
it('submits on Cmd+Enter and Ctrl+Enter', async () => {
const meta = mountForm()
await meta.find('textarea').setValue('a')
await meta.find('textarea').trigger('keydown', { key: 'Enter', metaKey: true })
expect(meta.emitted('submit')?.[0]).toEqual(['a'])
const ctrl = mountForm()
await ctrl.find('textarea').setValue('b')
await ctrl.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true })
expect(ctrl.emitted('submit')?.[0]).toEqual(['b'])
})
it('cancels on Escape', async () => {
const wrapper = mountForm()
await wrapper.find('textarea').trigger('keydown', { key: 'Escape' })
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('submit')).toBeUndefined()
})
it('focuses the textarea so the operator can just type', () => {
// Mounted alone in this test, so activeElement can only be this one.
const wrapper = mountForm()
expect(document.activeElement).toBe(wrapper.find('textarea').element)
})
it('blocks a second submit while one is in flight', async () => {
const wrapper = mountForm({ busy: true })
expect(wrapper.find('.submit-btn').attributes('disabled')).toBeDefined()
})
it('colours the action by tone', () => {
expect(mountForm({ tone: 'danger' }).find('.submit-btn').classes()).toContain('danger')
// Accent is the default, so the panel's re-run needs no tone prop.
expect(mountForm().find('.submit-btn').classes()).toContain('accent')
})
it('renders extra controls passed by the caller', () => {
const wrapper = mountForm({}, { default: '<label class="extra">Re-run too</label>' })
expect(wrapper.find('.extra').exists()).toBe(true)
})
})
@@ -0,0 +1,120 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import TicketConnectorsSection from '@/components/tickets/TicketConnectorsSection.vue'
import { mcpService } from '@/services/mcp'
import type { MCPTool } from '@/types/mcp'
vi.mock('@/services/mcp', () => ({
mcpService: {
getOrganizationMCPTools: vi.fn(),
createMCPTool: vi.fn(),
updateMCPTool: vi.fn(),
deleteMCPTool: vi.fn(),
getMCPToolReferences: vi.fn(),
},
}))
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const CONNECTOR: MCPTool = {
id: 7,
name: 'Elastic prod',
transport_type: 'http',
enabled: true,
url: 'https://kibana/api/agent_builder/mcp',
usage_guidance: 'Order id is fields.order_ref.',
organization_id: 'org-1',
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
}
function mountIt(connectors: MCPTool[] = []) {
vi.mocked(mcpService.getOrganizationMCPTools).mockResolvedValue(connectors)
return mount(TicketConnectorsSection, {
props: { selectedIds: [] },
global: { stubs: { 'font-awesome-icon': true } },
})
}
describe('TicketConnectorsSection guidance', () => {
beforeEach(() => vi.clearAllMocks())
it('sends the guidance the operator wrote when creating a connector', async () => {
vi.mocked(mcpService.createMCPTool).mockResolvedValue({ ...CONNECTOR, id: 9 })
const wrapper = mountIt()
await flushPromises()
await wrapper.findAll('.preset-tile').at(-1)!.trigger('click')
await wrapper.find('.field-input').setValue('Elastic prod')
await wrapper.find('.guidance-input').setValue('Order id is fields.order_ref.')
await wrapper.find('.connect-btn').trigger('click')
await flushPromises()
expect(vi.mocked(mcpService.createMCPTool).mock.calls[0][0]).toMatchObject({
usage_guidance: 'Order id is fields.order_ref.',
})
})
it('sends null rather than an empty string when no guidance is written', async () => {
vi.mocked(mcpService.createMCPTool).mockResolvedValue({ ...CONNECTOR, id: 9 })
const wrapper = mountIt()
await flushPromises()
await wrapper.findAll('.preset-tile').at(-1)!.trigger('click')
await wrapper.find('.field-input').setValue('Bare connector')
await wrapper.find('.connect-btn').trigger('click')
await flushPromises()
expect(vi.mocked(mcpService.createMCPTool).mock.calls[0][0].usage_guidance).toBeNull()
})
it('never seeds guidance from a preset description', async () => {
/**
* A preset's `desc` is a one-line marketing subtitle ("Errors, issues and
* traces"). Letting it become prompt text is exactly the failure this
* field exists to fix, so it must stay empty even though `description`
* is filled.
*/
const wrapper = mountIt()
await flushPromises()
await wrapper.findAll('.preset-tile')[0].trigger('click')
expect((wrapper.find('.guidance-input').element as HTMLTextAreaElement).value).toBe('')
expect((wrapper.find('.field-input').element as HTMLInputElement).value).toBe('Grafana')
})
it('loads existing guidance into the edit form and marks the row as guided', async () => {
const wrapper = mountIt([CONNECTOR])
await flushPromises()
expect(wrapper.find('.guided-tag').exists()).toBe(true)
await wrapper.find('.row-btn').trigger('click')
expect((wrapper.find('.guidance-input').element as HTMLTextAreaElement).value).toBe(
'Order id is fields.order_ref.',
)
})
it('shows no guided chip for an undocumented connector', async () => {
const wrapper = mountIt([{ ...CONNECTOR, usage_guidance: null }])
await flushPromises()
expect(wrapper.find('.guided-tag').exists()).toBe(false)
})
})
@@ -0,0 +1,214 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TicketInvestigationPanel from '../../../components/tickets/TicketInvestigationPanel.vue'
import type { InvestigationDetail, InvestigationRun } from '../../../types/ticket'
const baseRun: InvestigationRun = {
id: 'r1',
run_type: 'investigation',
status: 'completed',
trigger: 'manual',
tool_calls_used: 0,
}
const mountPanel = (run: Partial<InvestigationRun>, props: Record<string, unknown> = {}) =>
mount(TicketInvestigationPanel, {
props: {
investigation: {
run: { ...baseRun, ...run },
hypotheses: [],
events: [],
} as InvestigationDetail,
...props,
},
global: {
stubs: {
'font-awesome-icon': true,
'router-link': { template: '<a><slot /></a>' },
},
},
})
describe('TicketInvestigationPanel connector warning', () => {
it('warns when the run loaded fewer connectors than configured', () => {
const wrapper = mountPanel({
connector_status: {
configured: 1,
loaded: 0,
failed: [{ name: 'Elasticsearch', error: "[Errno 2] No such file or directory: 'npx'" }],
},
})
const warning = wrapper.find('.connector-warning')
expect(warning.exists()).toBe(true)
expect(warning.text()).toContain('0 / 1 个已配置连接器')
expect(warning.text()).toContain('Elasticsearch')
expect(warning.text()).toContain('npx')
})
it('stays silent when every configured connector loaded', () => {
const wrapper = mountPanel({
connector_status: { configured: 2, loaded: 2, failed: [] },
})
expect(wrapper.find('.connector-warning').exists()).toBe(false)
})
it('warns when every connector loaded but the provider refused their tools', () => {
// The #303 case: nothing "failed", so the loaded-vs-configured check alone
// reports a clean run that gathered no evidence at all.
const wrapper = mountPanel({
connector_status: {
configured: 1,
loaded: 1,
failed: [],
provider_errors: ["The model provider rejected a connected tool's schema."],
},
})
const warning = wrapper.find('.connector-warning')
expect(warning.exists()).toBe(true)
expect(warning.text()).toContain("无法使用")
expect(warning.text()).toContain('rejected a connected tool')
})
it('stays silent when the run has no connector status (older runs)', () => {
const wrapper = mountPanel({})
expect(wrapper.find('.connector-warning').exists()).toBe(false)
})
})
describe('TicketInvestigationPanel errored tool calls', () => {
/**
* A connector can come up, load its tools, and then error on every query.
* That reports loaded === configured with no provider errors, so before
* #318 the run rendered completely clean with a confident wrong answer.
*/
it('warns when every tool call errored, even with all connectors loaded', () => {
const wrapper = mountPanel({
connector_status: {
configured: 1,
loaded: 1,
failed: [],
tool_calls: 3,
tool_calls_failed: 3,
},
})
const text = wrapper.find('.connector-warning').text()
expect(text).toContain('3 / 3 次工具调用出错')
expect(text).toContain('此处结论均无证据支撑')
})
it('reports a partial failure without claiming nothing was gathered', () => {
const wrapper = mountPanel({
connector_status: {
configured: 1,
loaded: 1,
failed: [],
tool_calls: 4,
tool_calls_failed: 1,
},
})
const text = wrapper.find('.connector-warning').text()
expect(text).toContain('1 / 4 次工具调用出错')
expect(text).not.toContain('evidence-backed')
})
it('stays silent on a clean run', () => {
const wrapper = mountPanel({
connector_status: {
configured: 2,
loaded: 2,
failed: [],
tool_calls: 5,
tool_calls_failed: 0,
},
})
expect(wrapper.find('.connector-warning').exists()).toBe(false)
})
it('handles a run recorded before these counts existed', () => {
const wrapper = mountPanel({
connector_status: { configured: 1, loaded: 1, failed: [] },
})
expect(wrapper.find('.connector-warning').exists()).toBe(false)
})
})
describe('TicketInvestigationPanel guided re-run', () => {
/**
* Below autonomy 2 no proposal is created, so the L2 approval banner never
* renders — and it held the only reject-with-feedback path. An L1 operator
* had no way to correct a run short of editing the ticket description.
*/
it('offers a guided re-run when the user can start one', () => {
const wrapper = mountPanel({}, { canReinvestigate: true })
expect(wrapper.find('.rerun-btn').exists()).toBe(true)
})
it('is absent for a user who cannot manage tickets', () => {
expect(mountPanel({}, { canReinvestigate: false }).find('.panel-foot').exists()).toBe(false)
expect(mountPanel({}).find('.panel-foot').exists()).toBe(false)
})
it('disables the button with the reason a run is unavailable', () => {
const wrapper = mountPanel({}, {
canReinvestigate: true,
reinvestigateBlockedReason: 'This ticket is resolved — reopen it to investigate again',
})
const button = wrapper.find('.rerun-btn')
expect(button.attributes('disabled')).toBeDefined()
expect(button.attributes('title')).toContain('reopen it')
})
it('emits the trimmed note and closes the form', async () => {
const wrapper = mountPanel({}, { canReinvestigate: true })
await wrapper.find('.rerun-btn').trigger('click')
await wrapper.find('.note-input').setValue(' Use fields.order_ref, not order_id. ')
await wrapper.find('.submit-btn').trigger('click')
expect(wrapper.emitted('reinvestigate')?.[0]).toEqual(['Use fields.order_ref, not order_id.'])
// Back to the button, so a second run needs a deliberate click.
expect(wrapper.find('.note-input').exists()).toBe(false)
})
it('lets the operator back out without starting a run', async () => {
const wrapper = mountPanel({}, { canReinvestigate: true })
await wrapper.find('.rerun-btn').trigger('click')
await wrapper.find('.cancel-btn').trigger('click')
expect(wrapper.emitted('reinvestigate')).toBeUndefined()
expect(wrapper.find('.rerun-btn').exists()).toBe(true)
})
it('links to settings only for someone who can edit them', async () => {
const allowed = mountPanel({}, { canReinvestigate: true, canEditSettings: true })
await allowed.find('.rerun-btn').trigger('click')
expect(allowed.find('.rerun-scope a').exists()).toBe(true)
const denied = mountPanel({}, { canReinvestigate: true, canEditSettings: false })
await denied.find('.rerun-btn').trigger('click')
expect(denied.find('.rerun-scope a').exists()).toBe(false)
expect(denied.find('.rerun-scope').text()).toContain('Ticketing settings')
})
})
@@ -0,0 +1,95 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/composables/useUsers', () => ({
useUsers: () => ({ users: { value: [] }, fetchUsers: vi.fn().mockResolvedValue([]) }),
}))
import TicketSidePanel from '../../../components/tickets/TicketSidePanel.vue'
import type { Ticket } from '../../../types/ticket'
const baseTicket = {
id: 't1',
ticket_number: 12,
display_number: 'TKT-12',
organization_id: 'org1',
title: 'Refund never arrived',
status: 'closed',
priority: 'medium',
source: 'chat_ai',
reopened_count: 0,
} as Ticket
const createWrapper = (ticket: Partial<Ticket> = {}, linkedSessionIds: string[] = ['s1']) =>
mount(TicketSidePanel, {
props: {
ticket: { ...baseTicket, ...ticket } as Ticket,
linkedSessionIds,
canManage: false,
},
global: { stubs: { 'font-awesome-icon': true } },
})
const csatCard = (wrapper: ReturnType<typeof createWrapper>) =>
wrapper.findAll('.panel-card').find((card) => card.text().startsWith('CSAT'))!
describe('TicketSidePanel CSAT card', () => {
it('shows the score once the customer has rated', () => {
const card = csatCard(
createWrapper({
csat_requested_at: '2026-07-20T10:00:00Z',
csat_responded_at: '2026-07-20T11:00:00Z',
csat_score: 4,
}),
)
expect(card.text()).toContain('4/5')
expect(card.findAll('.csat-stars font-awesome-icon-stub')).toHaveLength(5)
expect(card.text()).toContain('Rated by the customer')
})
it('shows pending while the ask is out', () => {
const card = csatCard(createWrapper({ csat_requested_at: '2026-07-20T10:00:00Z' }))
expect(card.text()).toContain('待评价')
expect(card.find('.csat-stars').exists()).toBe(false)
})
it('goes back to pending when a reopened ticket is asked again', () => {
const card = csatCard(
createWrapper({
csat_score: 2,
csat_responded_at: '2026-07-20T11:00:00Z',
csat_requested_at: '2026-07-21T09:00:00Z',
}),
)
expect(card.text()).toContain('待评价')
expect(card.text()).not.toContain('2/5')
})
it('shows not requested before close', () => {
const card = csatCard(createWrapper())
expect(card.text()).toContain('未邀请评价')
expect(card.text()).toContain('工单关闭时会自动邀请评价')
})
it('explains why a ticket with no conversation is never asked', () => {
const card = csatCard(createWrapper({}, []))
expect(card.text()).toContain('没有可评价的关联会话')
})
})
@@ -0,0 +1,72 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect } from 'vitest'
import { accountToManage, reconnectWarning } from '@/utils/channelAccounts'
import type { ChannelAccount } from '@/services/channels'
const account = (overrides: Partial<ChannelAccount> = {}): ChannelAccount => ({
id: 'a1', channel_type: 'whatsapp', external_account_id: 'PN1',
display_name: 'Support (+1 555)', is_active: true, ...overrides,
})
describe('reconnectWarning', () => {
it('is silent when no account needs reconnecting', () => {
expect(reconnectWarning([])).toBeUndefined()
expect(reconnectWarning([account(), account({ needs_reconnect: false })])).toBeUndefined()
})
it('names the account that needs reconnecting', () => {
expect(reconnectWarning([account({ needs_reconnect: true })]))
.toBe('以下账号的访问权限已过期:Support (+1 555),请重新连接以恢复回复。')
})
it('names only the expired ones when several are connected', () => {
const warning = reconnectWarning([
account({ id: 'a1', display_name: 'Sales', needs_reconnect: true }),
account({ id: 'a2', display_name: 'Healthy' }),
account({ id: 'a3', display_name: 'Billing', needs_reconnect: true }),
])
expect(warning).toBe('以下账号的访问权限已过期:Sales, Billing,请重新连接以恢复回复。')
})
it('falls back to the platform id when the account has no name', () => {
expect(reconnectWarning([account({ display_name: undefined, needs_reconnect: true })]))
.toContain('以下账号的访问权限已过期:PN1')
})
})
describe('accountToManage', () => {
it('opens the account that needs reconnecting, even when it is not first', () => {
const healthy = account({ id: 'a1' })
const expired = account({ id: 'a2', needs_reconnect: true })
expect(accountToManage([healthy, expired])).toBe(expired)
})
it('opens the first expired one when several need reconnecting', () => {
const first = account({ id: 'a2', needs_reconnect: true })
expect(accountToManage([account({ id: 'a1' }), first, account({ id: 'a3', needs_reconnect: true })])).toBe(first)
})
it('opens the first account when every one is healthy', () => {
const first = account({ id: 'a1' })
expect(accountToManage([first, account({ id: 'a2' })])).toBe(first)
})
it('opens nothing when no account is connected', () => {
expect(accountToManage([])).toBeNull()
})
})
@@ -0,0 +1,144 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
parseSignupMessage,
signupLoginOptions,
runInstagramLogin,
runBusinessLogin,
} from '../../utils/metaSdk'
const message = (data: unknown, origin = 'https://www.facebook.com') =>
({ origin, data: typeof data === 'string' ? data : JSON.stringify(data) }) as MessageEvent
const FINISH = {
type: 'WA_EMBEDDED_SIGNUP',
event: 'FINISH',
data: { waba_id: 'WABA9', phone_number_id: 'PN555' },
}
describe('parseSignupMessage', () => {
it('reads the WABA and phone number from a finished signup', () => {
expect(parseSignupMessage(message(FINISH))).toEqual({
waba_id: 'WABA9',
phone_number_id: 'PN555',
})
})
it('accepts the other Facebook origins the flow can post from', () => {
for (const origin of ['https://web.facebook.com', 'https://business.facebook.com']) {
expect(parseSignupMessage(message(FINISH, origin))).not.toBeNull()
}
})
it('rejects a lookalike origin', () => {
// Meta's own sample uses origin.endsWith('facebook.com'), which these pass.
for (const origin of ['https://evil-facebook.com', 'https://facebook.com.attacker.io']) {
expect(parseSignupMessage(message(FINISH, origin))).toBeNull()
}
})
it('rejects a plain http facebook origin', () => {
expect(parseSignupMessage(message(FINISH, 'http://www.facebook.com'))).toBeNull()
})
it('ignores unrelated messages on a trusted origin', () => {
expect(parseSignupMessage(message({ type: 'SOMETHING_ELSE' }))).toBeNull()
expect(parseSignupMessage(message('not json at all'))).toBeNull()
})
it('ignores a signup that was abandoned rather than finished', () => {
expect(parseSignupMessage(message({ ...FINISH, event: 'CANCEL' }))).toBeNull()
})
it('ignores a finish missing either id', () => {
expect(parseSignupMessage(message({ ...FINISH, data: { waba_id: 'WABA9' } }))).toBeNull()
expect(parseSignupMessage(message({ ...FINISH, data: {} }))).toBeNull()
})
})
describe('signupLoginOptions', () => {
it('asks for a code rather than the default token response', () => {
expect(signupLoginOptions('CFG1')).toEqual({
config_id: 'CFG1',
response_type: 'code',
override_default_response_type: true,
extras: { setup: {} },
})
})
it('sends the WhatsApp setup extra for WhatsApp', () => {
expect(signupLoginOptions('CFG1', 'whatsapp').extras).toEqual({ setup: {} })
})
it('omits the setup extra for Facebook Login for Business', () => {
// extras.setup opens Embedded Signup; Messenger/Instagram must not send it.
expect(signupLoginOptions('CFG1', 'messenger')).not.toHaveProperty('extras')
expect(signupLoginOptions('CFG1', 'instagram')).not.toHaveProperty('extras')
})
})
describe('OAuth login popups', () => {
afterEach(() => vi.unstubAllGlobals())
/** Capture the URL the popup is opened with, without completing the login. */
const capturePopupUrl = (run: () => Promise<unknown>) => {
let opened = ''
vi.stubGlobal('open', (url: string) => {
opened = url
return { closed: false, close: () => {} }
})
// The promise stays pending (no callback message); we only want the URL.
void run().catch(() => {})
return new URL(opened)
}
it('sends Instagram to its own authorize endpoint, not a Facebook dialog', () => {
const url = capturePopupUrl(() =>
runInstagramLogin({ appId: 'IGAPP', redirectUri: 'https://app.test/cb.html' }))
expect(url.origin + url.pathname).toBe('https://www.instagram.com/oauth/authorize')
expect(url.searchParams.get('client_id')).toBe('IGAPP')
expect(url.searchParams.get('response_type')).toBe('code')
expect(url.searchParams.get('redirect_uri')).toBe('https://app.test/cb.html')
expect(url.searchParams.get('force_reauth')).toBe('true')
expect(url.searchParams.get('scope')).toContain('instagram_business_manage_messages')
// Least privilege: we only read the account and handle DMs, so no
// comments/publishing/insights scopes to enlarge App Review.
expect(url.searchParams.get('scope')).not.toContain('content_publish')
expect(url.searchParams.get('state')).toBeTruthy()
})
it('sends Messenger to the Facebook dialog with its configuration', () => {
const url = capturePopupUrl(() =>
runBusinessLogin({
appId: 'FBAPP', configId: 'CFG1', graphVersion: 'v21.0',
redirectUri: 'https://app.test/cb.html',
}))
expect(url.hostname).toBe('www.facebook.com')
expect(url.searchParams.get('config_id')).toBe('CFG1')
expect(url.searchParams.get('client_id')).toBe('FBAPP')
})
it('rejects when the browser blocks the popup', async () => {
vi.stubGlobal('open', () => null)
await expect(
runInstagramLogin({ appId: 'IGAPP', redirectUri: 'https://app.test/cb.html' }),
).rejects.toThrow(/弹窗被拦截/)
})
})
+528
View File
@@ -0,0 +1,528 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import type { FaqItem } from '@/types/faq'
import FaqSeoFields from './FaqSeoFields.vue'
import MarkdownEditor from './MarkdownEditor.vue'
const props = defineProps<{
faq: FaqItem
editing: boolean
isNew?: boolean
saving?: boolean
draftQuestion: string
draftAnswer: string
/** Per-article SEO drafts. Optional so callers that don't expose the SEO
* section (and tests) can leave them out — they default to empty. */
draftSlug?: string
draftUrlPath?: string
draftMetaTitle?: string
draftMetaDescription?: string
/** Topic (category) draft plus the org's existing topics for autocomplete.
* Optional for the same reason as the SEO drafts. */
draftCategory?: string
categories?: string[]
locked?: boolean
/** Selection mode is on somewhere in the list (keeps checkboxes visible). */
selectable?: boolean
selected?: boolean
}>()
const emit = defineEmits<{
'toggle-status': []
edit: []
delete: []
save: []
cancel: []
'toggle-select': []
'update:draftQuestion': [value: string]
'update:draftAnswer': [value: string]
'update:draftCategory': [value: string]
'update:draftSlug': [value: string]
'update:draftUrlPath': [value: string]
'update:draftMetaTitle': [value: string]
'update:draftMetaDescription': [value: string]
}>()
function onQuestionInput(event: Event) {
emit('update:draftQuestion', (event.target as HTMLInputElement).value)
}
function onCategoryInput(event: Event) {
emit('update:draftCategory', (event.target as HTMLInputElement).value)
}
const sourceLabel = () => props.faq.source_label || '自动生成'
// Mirrors the category column width in app/models/faq.py — the API 422s
// anything longer, so this only stops the user typing past the limit.
const MAX_TOPIC = 100
// Answers are stored as Markdown; strip the syntax and cap the length for the
// compact, line-clamped card preview (the public article page and the editor
// show the full formatted content).
const PREVIEW_MAX_CHARS = 240
function answerPreview(md: string): string {
const text = (md || '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[#>*_`~]/g, '')
.replace(/\s+/g, ' ')
.trim()
return text.length > PREVIEW_MAX_CHARS ? `${text.slice(0, PREVIEW_MAX_CHARS).trimEnd()}…` : text
}
</script>
<template>
<div class="faq-card" :class="{ 'faq-card--editing': editing, 'faq-card--selecting': selectable, 'faq-card--selected': selected }">
<!-- Edit mode -->
<div v-if="editing">
<div class="edit-label">{{ isNew ? '新建常见问题' : '编辑中' }}</div>
<input
class="edit-question"
type="text"
placeholder="问题"
:value="draftQuestion"
@input="onQuestionInput"
/>
<MarkdownEditor
class="edit-answer"
:model-value="draftAnswer"
@update:model-value="$emit('update:draftAnswer', $event)"
/>
<p class="edit-hint">支持 Markdown——标题、<strong>加粗</strong>、列表、链接和图片。</p>
<label class="edit-topic">
<span class="edit-topic__label">分类</span>
<input
type="text"
class="edit-topic__input"
list="faq-topic-options"
:maxlength="MAX_TOPIC"
:placeholder="isNew ? 'General' : '留空则保留当前分类'"
:value="draftCategory"
@input="onCategoryInput"
/>
<datalist id="faq-topic-options">
<option v-for="c in categories" :key="c" :value="c" />
</datalist>
<span class="edit-topic__hint">选择已有分类,或输入新分类以创建。</span>
</label>
<FaqSeoFields
:slug="draftSlug"
:url-path="draftUrlPath"
:meta-title="draftMetaTitle"
:meta-description="draftMetaDescription"
:question="draftQuestion"
@update:slug="$emit('update:draftSlug', $event)"
@update:url-path="$emit('update:draftUrlPath', $event)"
@update:meta-title="$emit('update:draftMetaTitle', $event)"
@update:meta-description="$emit('update:draftMetaDescription', $event)"
/>
<div class="edit-actions">
<button class="btn-save" type="button" :disabled="saving" @click="$emit('save')">
{{ saving ? '保存中…' : '保存' }}
</button>
<button class="btn-cancel" type="button" :disabled="saving" @click="$emit('cancel')">取消</button>
</div>
</div>
<!-- Display mode -->
<div v-else class="faq-card__row">
<input
v-if="!locked"
class="faq-card__check"
type="checkbox"
:checked="selected"
:aria-label="`选择 ${faq.question}`"
@change="$emit('toggle-select')"
/>
<div class="faq-card__body">
<div class="faq-card__question">{{ faq.question }}</div>
<div class="faq-card__answer">{{ answerPreview(faq.answer) }}</div>
<div class="faq-card__source">
<span class="faq-card__source-dot"></span>
{{ sourceLabel() }}
<span
v-if="(faq.helpful_yes || 0) + (faq.helpful_no || 0) > 0"
class="faq-card__fb"
:title="`${faq.helpful_yes || 0} 人觉得有帮助,${faq.helpful_no || 0} 人觉得没帮助`"
>
<span class="faq-card__fb-item faq-card__fb-item--up">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M7 11v9H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" /><path d="M7 11l4-8a2 2 0 0 1 3 1.8V9h4.5a2 2 0 0 1 2 2.4l-1.4 7A2 2 0 0 1 17 20H7" /></svg>
{{ faq.helpful_yes || 0 }}
</span>
<span class="faq-card__fb-item faq-card__fb-item--down">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M17 13V4h3a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1z" /><path d="M17 13l-4 8a2 2 0 0 1-3-1.8V15H5.5a2 2 0 0 1-2-2.4l1.4-7A2 2 0 0 1 7 4h10" /></svg>
{{ faq.helpful_no || 0 }}
</span>
</span>
</div>
</div>
<div class="faq-card__controls">
<button
class="pill"
:class="faq.status === 'published' ? 'pill--published' : 'pill--draft'"
type="button"
:disabled="locked"
@click="$emit('toggle-status')"
>
<span class="pill__dot"></span>
{{ faq.status === 'published' ? '已发布' : '草稿' }}
</button>
<button v-if="!locked" class="icon-btn icon-btn--edit" type="button" title="编辑" @click="$emit('edit')">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></svg>
</button>
<button v-if="!locked" class="icon-btn icon-btn--delete" type="button" title="删除" @click="$emit('delete')">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" /></svg>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.faq-card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 14px;
padding: 16px 18px;
}
.faq-card--editing {
border-color: var(--purple-border);
}
.faq-card--selected {
border-color: var(--teal-border);
background: var(--teal-bg);
}
.faq-card__check {
width: 16px;
height: 16px;
margin: 3px 0 0;
flex-shrink: 0;
accent-color: var(--c-teal);
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast);
}
.faq-card:hover .faq-card__check,
.faq-card--selecting .faq-card__check,
.faq-card__check:checked,
.faq-card__check:focus-visible {
opacity: 1;
}
.faq-card__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.faq-card__body {
flex: 1;
min-width: 0;
}
.faq-card__question {
font-size: 15px;
font-weight: 600;
color: var(--text2);
line-height: 1.4;
margin-bottom: 6px;
}
.faq-card__answer {
font-size: 13.5px;
color: var(--muted);
line-height: 1.55;
/* Keep every card compact and uniform — imported articles can be very long;
full content is on the public page and in the editor. */
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.faq-card__source {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 11px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--faint);
}
.faq-card__source-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--faint);
}
.faq-card__fb {
display: inline-flex;
align-items: center;
gap: 9px;
padding-left: 9px;
margin-left: 2px;
border-left: 1px solid var(--o10);
}
.faq-card__fb-item {
display: inline-flex;
align-items: center;
gap: 3px;
}
.faq-card__fb-item--up {
color: var(--c-teal, #1d9e75);
}
.faq-card__fb-item--down {
color: var(--c-coral, #d85a30);
}
.faq-card__controls {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.pill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 13px;
border-radius: 9px;
font-family: var(--font-sans);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.pill:disabled {
cursor: default;
}
.pill__dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.pill--published {
background: var(--teal-bg);
border: 1px solid var(--teal-border);
color: var(--c-teal);
}
.pill--published .pill__dot {
background: var(--c-teal);
}
.pill--draft {
background: var(--pill-idle-bg);
border: 1px solid var(--o12);
color: var(--pill-idle-fg);
}
.pill--draft .pill__dot {
background: var(--faint);
}
.icon-btn {
width: 32px;
height: 32px;
border-radius: 9px;
background: var(--o05);
border: 1px solid var(--o12);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color var(--transition-fast), color var(--transition-fast);
}
.icon-btn--edit:hover {
background: var(--o08);
color: var(--text2);
}
.icon-btn--delete:hover {
background: var(--coral-bg);
color: var(--c-coral);
border-color: var(--coral-border);
}
.edit-label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.08em;
color: var(--c-purple);
margin-bottom: 10px;
}
.edit-question {
width: 100%;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 10px;
padding: 11px 13px;
color: var(--text);
outline: none;
box-sizing: border-box;
font-size: 14.5px;
font-weight: 600;
margin-bottom: 9px;
}
.edit-topic {
display: block;
margin-top: 12px;
}
.edit-topic__label {
display: block;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted2);
margin-bottom: 6px;
}
.edit-topic__input {
width: 100%;
max-width: 320px;
display: block;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 10px;
padding: 9px 12px;
color: var(--text);
outline: none;
box-sizing: border-box;
font-family: var(--font-sans);
font-size: 13.5px;
}
.edit-topic__hint {
display: block;
margin-top: 6px;
font-size: 11.5px;
color: var(--muted2);
}
/* .edit-answer wraps the MarkdownEditor, which supplies its own chrome. */
.edit-hint {
margin: 8px 0 0;
font-size: 11.5px;
color: var(--muted2);
}
.edit-hint strong {
font-weight: 600;
}
.edit-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.btn-save {
padding: 9px 18px;
background: var(--accent-solid);
border: none;
border-radius: 9px;
color: var(--on-accent-solid);
font-family: var(--font-sans);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: default;
}
.btn-cancel {
padding: 9px 16px;
background: transparent;
border: 1px solid var(--o12);
border-radius: 9px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
}
/* ── Mobile ──────────────────────────────────────────────────────────────
The status pill plus two icon buttons took ~200px of a 375px row, leaving
the question a column so narrow it wrapped a word per line. Below 768px
the controls drop to their own line and the question gets the full width. */
@media (max-width: 768px) {
.faq-card__row {
flex-wrap: wrap;
gap: 10px;
}
.faq-card__body {
flex: 1 1 auto;
}
.faq-card__controls {
flex: 1 0 100%;
justify-content: flex-end;
}
.faq-card__question {
font-size: 15px;
}
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
.icon-btn {
width: 44px;
height: 44px;
}
.pill {
min-height: 44px;
}
.edit-actions {
flex-wrap: wrap;
}
.btn-save,
.btn-cancel {
flex: 1 1 auto;
min-height: 44px;
}
}
</style>
@@ -0,0 +1,186 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
const props = defineProps<{
category: string
count: number
/** Published FAQs in this group (for the header stat). */
publishedCount?: number
/** Cards in this group currently selected; undefined hides the select-all. */
selectedCount?: number
/** Whether the group body is expanded. */
open?: boolean
}>()
defineEmits<{
'toggle-all': [on: boolean]
toggle: []
}>()
const allSelected = () => props.selectedCount === props.count && props.count > 0
</script>
<template>
<div class="category-group">
<div class="category-group__header">
<input
v-if="selectedCount !== undefined"
class="category-group__check"
type="checkbox"
:checked="allSelected()"
:indeterminate.prop="!!selectedCount && !allSelected()"
:aria-label="`全选 ${category}`"
@click.stop
@change="$emit('toggle-all', !allSelected())"
/>
<button
class="category-group__toggle"
type="button"
:aria-expanded="open"
@click="$emit('toggle')"
>
<svg
class="category-group__chevron"
:class="{ 'category-group__chevron--open': open }"
viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"
><path d="M9 6l6 6-6 6" /></svg>
<span class="category-group__name">{{ category.toUpperCase() }}</span>
<span class="category-group__count">
{{ count }} 条回答<template v-if="publishedCount !== undefined"> · {{ publishedCount }} 条已发布</template>
</span>
</button>
<div class="category-group__rule"></div>
</div>
<div v-show="open" class="category-group__cards">
<slot />
</div>
</div>
</template>
<style scoped>
.category-group {
margin-bottom: 18px;
}
.category-group__header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
padding: 0 2px;
}
.category-group__check {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--c-teal);
cursor: pointer;
opacity: 0.55;
transition: opacity var(--transition-fast);
}
.category-group__header:hover .category-group__check,
.category-group__check:checked,
.category-group__check:indeterminate {
opacity: 1;
}
.category-group__toggle {
display: inline-flex;
align-items: center;
gap: 8px;
background: transparent;
border: none;
padding: 2px 0;
cursor: pointer;
color: inherit;
}
.category-group__chevron {
color: var(--muted2);
flex-shrink: 0;
transition: transform var(--transition-fast);
}
.category-group__chevron--open {
transform: rotate(90deg);
}
.category-group__name {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--accent-ink);
}
.category-group__count {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted2);
}
.category-group__rule {
flex: 1;
height: 1px;
background: var(--o07);
}
.category-group__cards {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Mobile ─────────────────────────────────────────────────────────────
The decorative rule took flex:1 of the header, squeezing the toggle until
the name broke mid-phrase ("ACCOUNT &" / "SECURITY").
Grid rather than flex-wrap: the chevron owns column 1 and the text owns
column 2, so the count sits under the name and the chevron can never be
pushed onto a line of its own however narrow the screen or long the
category name. */
@media (max-width: 768px) {
.category-group__rule {
display: none;
}
.category-group__toggle {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
column-gap: 8px;
row-gap: 2px;
text-align: left;
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
min-height: 44px;
}
.category-group__chevron {
grid-row: 1 / span 2;
}
.category-group__name,
.category-group__count {
grid-column: 2;
min-width: 0;
}
}
</style>
@@ -0,0 +1,159 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import FaqOrb from './FaqOrb.vue'
defineProps<{
sourceCount: number
pageCount: number
}>()
defineEmits<{
generate: []
import: []
add: []
}>()
</script>
<template>
<div class="empty-state">
<FaqOrb class="empty-state__orb" :size="64" />
<h3 class="empty-state__title">还没有常见问题</h3>
<p class="empty-state__copy">
让 ChatterMate 阅读你的知识库,起草清晰、可直接发布的常见问题——按分类整理,并随文档更新保持同步。
</p>
<div class="empty-state__pill">
<span class="empty-state__dot"></span>
读取 {{ sourceCount }} 个来源 · {{ pageCount }} 个页面
</div>
<div class="empty-state__actions">
<button class="btn btn--primary" type="button" @click="$emit('generate')">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" /></svg>
从知识库生成
</button>
<button class="btn btn--ghost" type="button" @click="$emit('import')">导入已有常见问题</button>
<button class="btn btn--text" type="button" @click="$emit('add')">手动添加</button>
</div>
</div>
</template>
<style scoped>
.empty-state {
background: var(--surface);
border: 1px dashed var(--o14);
border-radius: 16px;
padding: 56px 32px;
text-align: center;
}
.empty-state__orb {
margin: 0 auto 22px;
}
.empty-state__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: -0.01em;
color: var(--text);
margin: 0 0 10px;
}
.empty-state__copy {
font-size: 14.5px;
color: var(--muted);
line-height: 1.6;
margin: 0 auto 26px;
max-width: 440px;
}
.empty-state__pill {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 8px 14px;
border-radius: var(--radius-pill);
background: var(--o04);
border: 1px solid var(--o10);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text3);
margin-bottom: 26px;
}
.empty-state__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-teal);
box-shadow: 0 0 6px var(--c-teal);
}
.empty-state__actions {
display: flex;
gap: 11px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 9px;
border-radius: 12px;
font-family: var(--font-sans);
font-size: 15px;
cursor: pointer;
transition: filter var(--transition-fast), background-color var(--transition-fast);
}
.btn--primary {
padding: 14px 24px;
background: var(--grad-generate);
border: none;
color: var(--on-dark);
font-weight: 600;
}
.btn--primary:hover {
filter: brightness(1.1);
}
.btn--ghost {
padding: 14px 20px;
background: var(--o05);
border: 1px solid var(--o14);
color: var(--text2);
font-weight: 600;
}
.btn--ghost:hover {
background: var(--o08);
}
.btn--text {
padding: 14px 20px;
background: transparent;
border: 1px solid var(--o12);
color: var(--muted);
font-weight: 500;
}
.btn--text:hover {
background: var(--o04);
}
</style>
@@ -0,0 +1,210 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import FaqOrb from './FaqOrb.vue'
const props = defineProps<{
phase: 'idle' | 'generating' | 'ready'
sourceCount: number
pageCount: number
faqCount: number
publishedCount: number
/** Sources without FAQs yet (from the estimate); null = unknown. */
newSourceCount?: number | null
disabled?: boolean
}>()
defineEmits<{
generate: []
import: []
add: []
}>()
const title = computed(() => {
if (props.phase === 'generating') return '正在生成常见问题…'
if (props.phase === 'ready') return '常见问题待发布'
return '生成常见问题'
})
const subtitle = computed(() =>
props.phase === 'ready'
? `${props.faqCount} 条常见问题 · ${props.publishedCount} 条已发布`
: `读取 ${props.sourceCount} 个来源 · ${props.pageCount} 个页面`,
)
const generateLabel = computed(() => {
if (props.phase === 'generating') return '生成中…'
if (props.phase === 'ready') {
// Regenerate only reads new (ungenerated) sources — say so.
if (typeof props.newSourceCount === 'number' && props.newSourceCount > 0) {
return `生成 ${props.newSourceCount} 个新来源`
}
return '重新生成'
}
return '生成'
})
const noNewSources = computed(() => props.phase === 'ready' && props.newSourceCount === 0)
const generateDisabled = computed(() => props.phase === 'generating' || props.disabled || noNewSources.value)
const generateTitle = computed(() =>
noNewSources.value ? '所有知识来源都已生成常见问题' : undefined,
)
</script>
<template>
<div class="generate-bar">
<div class="generate-bar__lead">
<FaqOrb :size="40" />
<div>
<div class="generate-bar__title">{{ title }}</div>
<div class="generate-bar__sub">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A2 2 0 0 1 6 4h5v16H6a2 2 0 0 0-2 1.5z" /><path d="M20 5.5A2 2 0 0 0 18 4h-5v16h5a2 2 0 0 1 2 1.5z" /></svg>
{{ subtitle }}
</div>
</div>
</div>
<div class="generate-bar__actions">
<button class="btn btn--ghost" type="button" @click="$emit('import')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M5 21h14" /></svg>
导入
</button>
<button class="btn btn--ghost" type="button" @click="$emit('add')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14" /></svg>
添加常见问题
</button>
<button class="btn btn--generate" type="button" :disabled="generateDisabled" :title="generateTitle" @click="$emit('generate')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3v4M3 5h4M6 17v4M4 19h4M13 3l2.5 6.5L22 12l-6.5 2.5L13 21l-2.5-6.5L4 12l6.5-2.5z" /></svg>
{{ generateLabel }}
</button>
</div>
</div>
</template>
<style scoped>
.generate-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 22px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 16px;
flex-wrap: wrap;
}
.generate-bar__lead {
display: flex;
align-items: center;
gap: 14px;
}
.generate-bar__title {
font-size: 14.5px;
font-weight: 600;
color: var(--text2);
}
.generate-bar__sub {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--muted);
margin-top: 2px;
}
.generate-bar__actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-radius: 11px;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background-color var(--transition-fast);
}
.btn--ghost {
background: var(--o05);
border: 1px solid var(--o14);
color: var(--text2);
}
.btn--ghost:hover {
background: var(--o08);
}
.btn--generate {
padding: 12px 20px;
background: var(--purple-bg);
border: 1px solid var(--purple-border);
color: var(--c-purple);
}
.btn--generate:disabled {
opacity: 0.6;
cursor: default;
}
/* ── Mobile ─────────────────────────────────────────────────────────────
Three actions wrapped 2 + 1, leaving a stranded Regenerate button. Below
768px they share one row as equal thirds. */
@media (max-width: 768px) {
.generate-bar {
padding: 14px 16px;
gap: 14px;
}
.generate-bar__actions {
width: 100%;
gap: 8px;
flex-wrap: nowrap;
}
.btn {
flex: 1 1 0;
min-width: 0;
justify-content: center;
padding: 12px 8px;
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
min-height: 44px;
white-space: nowrap;
}
}
/* Labels alone would overflow three-up on the narrowest phones */
@media (max-width: 400px) {
.btn {
font-size: 12.5px;
gap: 5px;
}
}
</style>
@@ -0,0 +1,205 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { FaqGenerationJob, FaqJobStage } from '@/types/faq'
import FaqOrb from './FaqOrb.vue'
const props = defineProps<{ job: FaqGenerationJob }>()
const STAGE_ORDER: FaqJobStage[] = ['analyzing_sources', 'extracting', 'drafting', 'grouping']
const STAGE_LABELS = [
'分析你的来源',
'从内容中提取问题',
'撰写有依据的回答',
'按分类整理',
]
const steps = computed(() => {
const stage = props.job.stage
// Before the first stage report, the first step is active; when the job
// finishes, everything is done.
const activeIndex = stage === 'completed' ? STAGE_ORDER.length : Math.max(0, STAGE_ORDER.indexOf(stage))
return STAGE_LABELS.map((label, index) => ({
label,
state: index < activeIndex ? 'done' : index === activeIndex ? 'active' : 'todo',
}))
})
</script>
<template>
<div class="progress-card">
<div class="progress-card__head">
<FaqOrb :size="48" />
<div>
<div class="progress-card__title">正在读取你的知识库…</div>
<div class="progress-card__note">
通常不到一分钟即可完成。你可以离开此页面,我们会在后台继续处理。
</div>
</div>
</div>
<div class="progress-track">
<div class="progress-track__sweep"></div>
</div>
<div class="steps">
<div v-for="step in steps" :key="step.label" class="step" :class="step.state">
<span class="step__icon">
<svg v-if="step.state === 'done'" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--on-accent-solid)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l4 4 10-10" /></svg>
<span v-else-if="step.state === 'active'" class="step__spinner"></span>
<span v-else class="step__dot"></span>
</span>
<span class="step__label">{{ step.label }}</span>
<span v-if="step.state === 'active'" class="step__pulse">
<span></span><span></span><span></span>
</span>
</div>
</div>
</div>
</template>
<style scoped>
.progress-card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 16px;
padding: 40px 32px;
}
.progress-card__head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 26px;
}
.progress-card__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 19px;
letter-spacing: -0.01em;
color: var(--text);
}
.progress-card__note {
font-size: 13.5px;
color: var(--muted);
margin-top: 3px;
}
.progress-track {
height: 6px;
border-radius: var(--radius-pill);
background: var(--o08);
overflow: hidden;
margin-bottom: 26px;
position: relative;
}
.progress-track__sweep {
position: absolute;
top: 0;
bottom: 0;
width: 38%;
border-radius: var(--radius-pill);
background: var(--grad-generate);
animation: faq-progress 1.5s ease-in-out infinite;
}
@keyframes faq-progress {
0% { transform: translateX(-100%); }
100% { transform: translateX(320%); }
}
.steps {
display: flex;
flex-direction: column;
gap: 14px;
}
.step {
display: flex;
align-items: center;
gap: 13px;
}
.step__icon {
width: 24px;
height: 24px;
flex-shrink: 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.step.done .step__icon {
background: var(--accent-solid);
}
.step__spinner {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--c-purple);
border-top-color: transparent;
animation: faq-spin 0.8s linear infinite;
}
@keyframes faq-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.step__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--faint);
}
.step__label {
font-size: 14px;
font-weight: 500;
color: var(--text2);
}
.step.todo .step__label {
color: var(--muted2);
}
.step__pulse {
display: inline-flex;
gap: 3px;
margin-left: 2px;
}
.step__pulse span {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--c-purple);
animation: faq-pulse 1s ease-in-out infinite;
}
.step__pulse span:nth-child(2) { animation-delay: 0.2s; }
.step__pulse span:nth-child(3) { animation-delay: 0.4s; }
@keyframes faq-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
</style>
@@ -0,0 +1,471 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import Modal from '@/components/common/Modal.vue'
import FaqOrb from './FaqOrb.vue'
import type { FaqImportMode } from '@/types/faq'
const props = defineProps<{
open: boolean
submitting?: boolean
}>()
const emit = defineEmits<{
close: []
submit: [url: string, mode: FaqImportMode, preserveUrls: boolean]
'submit-pdf': [file: File]
}>()
const url = ref('')
const mode = ref<FaqImportMode>('qa')
const pdfFile = ref<File | null>(null)
// Article mode only: keep every imported article at the URL it already has.
const preserveUrls = ref(true)
const MODES: { value: FaqImportMode; title: string; description: string }[] = [
{
value: 'qa',
title: '问答页面',
description: 'AI 读取单个页面并提取每一组问答,会消耗 AI 额度。',
},
{
value: 'articles',
title: '文章页面',
description: '按原样导入该页面链接到的所有文章——文本、图片和链接,不消耗 AI。',
},
{
value: 'pdf',
title: 'PDF 文档',
description: 'AI 从上传的 PDF 中提取问答,会消耗 AI 额度。',
},
]
watch(
() => props.open,
(open) => {
if (!open) {
url.value = ''
mode.value = 'qa'
pdfFile.value = null
preserveUrls.value = true
}
},
)
// The option only exists for article mode; reset it on every mode change so
// switching away and back can't carry a stale choice into the next import.
watch(mode, () => {
preserveUrls.value = true
})
const canSubmit = computed(() => {
if (props.submitting) return false
if (mode.value === 'pdf') return pdfFile.value !== null
return url.value.trim().length > 0
})
const hint = computed(() => {
if (mode.value === 'articles')
return 'ChatterMate 会跟随该页面上的文章链接,按原文将每篇文章导入为草稿——包含格式和图片。'
if (mode.value === 'pdf')
return 'ChatterMate 会读取 PDF,提取每组问答并作为草稿添加到这里,供你审核后发布。'
return 'ChatterMate 会抓取该页面,提取每组问答并作为草稿添加到这里,供你审核后发布。'
})
const fileSize = computed(() => {
const bytes = pdfFile.value?.size ?? 0
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
})
const dragOver = ref(false)
function setPdf(file: File | null | undefined) {
if (file && file.type === 'application/pdf') pdfFile.value = file
else if (file) toast.error('请选择 PDF 文件')
}
function onPdfChange(event: Event) {
setPdf((event.target as HTMLInputElement).files?.[0] ?? null)
}
function onDrop(event: DragEvent) {
dragOver.value = false
setPdf(event.dataTransfer?.files?.[0])
}
function submit() {
if (!canSubmit.value) return
if (mode.value === 'pdf') {
if (pdfFile.value) emit('submit-pdf', pdfFile.value)
return
}
const cleaned = url.value.trim().replace(/^https?:\/\//i, '')
emit('submit', `https://${cleaned}`, mode.value, mode.value === 'articles' && preserveUrls.value)
}
</script>
<template>
<Modal v-if="open" @close="$emit('close')">
<template #title>迁移现有帮助中心</template>
<template #content>
<p class="import-sub">粘贴帮助中心或常见问题页面的 URL,并选择导入方式。</p>
<div class="mode-cards" role="radiogroup" aria-label="导入方式">
<label
v-for="option in MODES"
:key="option.value"
class="mode-card"
:class="{ 'mode-card--active': mode === option.value }"
>
<input v-model="mode" class="mode-card__radio" type="radio" name="faq-import-mode" :value="option.value" />
<span class="mode-card__title">{{ option.title }}</span>
<span class="mode-card__desc">{{ option.description }}</span>
</label>
</div>
<template v-if="mode !== 'pdf'">
<label class="import-label" for="faq-import-url">{{ mode === 'articles' ? '帮助中心索引 URL' : '常见问题页面 URL' }}</label>
<div class="import-input">
<span class="import-input__prefix">https://</span>
<input
id="faq-import-url"
v-model="url"
type="text"
placeholder="support.yourcompany.com/faq"
@keydown.enter="submit"
/>
</div>
</template>
<template v-else>
<label class="import-label" for="faq-import-pdf">PDF 文件(最大 25MB)</label>
<label
class="pdf-drop"
:class="{ 'pdf-drop--filled': pdfFile, 'pdf-drop--drag': dragOver }"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="onDrop"
>
<input id="faq-import-pdf" class="pdf-drop__input" type="file" accept="application/pdf" @change="onPdfChange" />
<span class="pdf-drop__icon">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" /><path d="M9 13h6M9 17h4" /></svg>
</span>
<span class="pdf-drop__text">
<template v-if="pdfFile">
<span class="pdf-drop__name">{{ pdfFile.name }}</span>
<span class="pdf-drop__meta">{{ fileSize }} · 重新选择文件</span>
</template>
<template v-else>
<span class="pdf-drop__name">选择 PDF 或拖放到此处</span>
<span class="pdf-drop__meta">仅支持 PDF,最大 25 MB</span>
</template>
</span>
</label>
</template>
<label v-if="mode === 'articles'" class="preserve">
<input v-model="preserveUrls" class="preserve__box" type="checkbox" />
<span class="preserve__text">
<span class="preserve__title">保留文章原始 URL</span>
<span class="preserve__desc">
每篇文章仍保留现有的 URL 路径,因此搜索排名和已有链接在迁移后依然有效。将旧域名指向这里,访客不会察觉任何变化。
</span>
</span>
</label>
<div class="import-hint">
<FaqOrb :size="34" />
<div>{{ hint }}</div>
</div>
<div class="import-actions">
<button class="btn-cancel" type="button" @click="$emit('close')">取消</button>
<button class="btn-import" type="button" :disabled="!canSubmit" @click="submit">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /><path d="M8 11l4 4 4-4" /><path d="M5 21h14" /></svg>
{{ submitting ? '导入中…' : mode === 'articles' ? '导入文章' : mode === 'pdf' ? '导入 PDF' : '导入常见问题' }}
</button>
</div>
</template>
</Modal>
</template>
<style scoped>
.import-sub {
font-size: 13px;
color: var(--muted);
margin: -12px 0 18px;
line-height: 1.5;
}
.mode-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 18px;
}
@media (max-width: 560px) {
.mode-cards {
grid-template-columns: 1fr;
}
}
.mode-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 13px 14px;
background: var(--o03);
border: 1px solid var(--o12);
border-radius: 12px;
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.mode-card--active {
background: var(--purple-bg);
border-color: var(--purple-border);
}
.mode-card__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.mode-card__title {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.mode-card--active .mode-card__title {
color: var(--c-purple);
}
.mode-card__desc {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.import-label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted2);
margin-bottom: 8px;
}
.import-input {
display: flex;
align-items: center;
gap: 10px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 11px;
padding: 0 14px;
margin-bottom: 16px;
font-family: var(--font-mono);
}
.import-input__prefix {
color: var(--faint);
font-size: 13.5px;
}
.import-input input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 13.5px;
padding: 13px 4px;
font-family: var(--font-mono);
}
.pdf-drop {
display: flex;
align-items: center;
gap: 13px;
padding: 16px 18px;
margin-bottom: 16px;
background: var(--bg);
border: 1.5px dashed var(--o14);
border-radius: 12px;
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.pdf-drop:hover {
border-color: var(--c-purple);
background: var(--purple-bg);
}
.pdf-drop--filled {
border-style: solid;
border-color: var(--purple-border);
background: var(--purple-bg);
}
.pdf-drop--drag {
border-style: solid;
border-color: var(--c-purple);
background: var(--purple-bg);
}
.pdf-drop__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.pdf-drop__icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 10px;
background: var(--o05);
color: var(--muted);
}
.pdf-drop--filled .pdf-drop__icon {
background: var(--surface);
color: var(--c-purple);
}
.pdf-drop__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.pdf-drop__name {
font-size: 14px;
font-weight: 600;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdf-drop__meta {
font-size: 12px;
color: var(--muted);
}
.preserve {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 13px 14px;
margin-bottom: 16px;
background: var(--o03);
border: 1px solid var(--o12);
border-radius: 12px;
cursor: pointer;
}
.preserve__box {
flex-shrink: 0;
width: 15px;
height: 15px;
margin-top: 1px;
accent-color: var(--c-purple);
cursor: pointer;
}
.preserve__text {
display: flex;
flex-direction: column;
gap: 3px;
}
.preserve__title {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.preserve__desc {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.import-hint {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--o03);
border: 1px solid var(--o07);
border-radius: 12px;
margin-bottom: 20px;
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.import-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.btn-cancel {
padding: 11px 18px;
background: transparent;
border: 1px solid var(--o12);
border-radius: 10px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-import {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 20px;
background: var(--c-purple);
border: none;
border-radius: 10px;
color: var(--on-accent);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.btn-import:disabled {
opacity: 0.55;
cursor: default;
}
</style>
+86
View File
@@ -0,0 +1,86 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{ size?: number }>(), { size: 40 })
const style = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
'--orb-blur': `${props.size * 0.05}px`,
'--orb-inset': `${props.size * 0.21}px`,
'--orb-shadow': `${props.size * 0.22}px`,
}))
</script>
<template>
<div class="orb" :style="style" aria-hidden="true">
<div class="orb__gradient"></div>
<div class="orb__core"></div>
<div class="orb__ring"></div>
</div>
</template>
<style scoped>
.orb {
position: relative;
flex-shrink: 0;
}
.orb__gradient {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(
from 0deg,
var(--c-purple),
var(--c-teal),
var(--accent-ink),
var(--c-coral),
var(--c-purple)
);
filter: blur(var(--orb-blur));
animation: orb-spin 6s linear infinite;
}
.orb__core {
position: absolute;
inset: var(--orb-inset);
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, rgba(255, 255, 255, 0.92), var(--o12) 55%, transparent 72%);
animation: orb-pulse 2.6s ease-in-out infinite;
}
.orb__ring {
position: absolute;
inset: 0;
border-radius: 50%;
box-shadow: inset 0 0 var(--orb-shadow) rgba(0, 0, 0, 0.4);
}
@keyframes orb-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes orb-pulse {
0% { opacity: 0.4; }
50% { opacity: 1; }
100% { opacity: 0.4; }
}
</style>
@@ -0,0 +1,177 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
import { useSubscriptionStorage } from '@/utils/storage'
const props = defineProps<{
/** Server-side plan verdict (settings.plan_allowed). false always locks. */
planAllowed?: boolean | null
}>()
const PRO_PRICE_NOTE = '包含在 Pro 套餐中 · 每位智能体 $39/月'
const router = useRouter()
const { hasEnterpriseModule } = useEnterpriseFeatures()
const subscriptionStorage = useSubscriptionStorage()
const isLocked = computed(() => {
if (props.planAllowed === false) return true
return (
hasEnterpriseModule &&
(!subscriptionStorage.hasFeature('help_center') || !subscriptionStorage.isSubscriptionActive())
)
})
function goToSubscription() {
router.push('/settings/subscription')
}
</script>
<template>
<div class="lock-wrap">
<div class="lock-wrap__content" :class="{ 'lock-wrap__content--locked': isLocked }">
<slot />
</div>
<div v-if="isLocked" class="lock-overlay">
<div class="lock-card">
<div class="lock-card__icon">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="var(--c-purple)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="10" rx="2.2" /><path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg>
</div>
<h3 class="lock-card__title">用 Pro 自动生成常见问题</h3>
<p class="lock-card__copy">
ChatterMate 会读取每个知识来源,起草清晰、可直接发布的常见问题——按分类整理,并随文档更新保持同步。
</p>
<div class="lock-card__actions">
<button class="btn-upgrade" type="button" @click="goToSubscription">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" stroke="none"><path d="M13 2 4 14h6l-1 8 9-12h-6z" /></svg>
升级到 Pro
</button>
<button class="btn-compare" type="button" @click="goToSubscription">比较套餐</button>
</div>
<div class="lock-card__note">{{ PRO_PRICE_NOTE }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.lock-wrap {
position: relative;
}
.lock-wrap__content--locked {
filter: grayscale(0.9) saturate(0.5);
opacity: 0.55;
pointer-events: none;
user-select: none;
}
.lock-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--scrim);
backdrop-filter: blur(6px);
border-radius: 18px;
}
.lock-card {
max-width: 440px;
text-align: center;
background: var(--surface);
border: 1px solid var(--purple-border);
border-radius: 20px;
padding: 34px 32px;
box-shadow: var(--shadow-lg);
}
.lock-card__icon {
width: 52px;
height: 52px;
margin: 0 auto 18px;
border-radius: 14px;
background: var(--purple-bg);
border: 1px solid var(--purple-border);
display: flex;
align-items: center;
justify-content: center;
}
.lock-card__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 21px;
letter-spacing: -0.01em;
color: var(--text);
margin: 0 0 10px;
}
.lock-card__copy {
font-size: 14.5px;
color: var(--muted);
line-height: 1.6;
margin: 0 0 22px;
}
.lock-card__actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.btn-upgrade {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 13px 22px;
background: var(--c-purple);
border: 1px solid var(--c-purple);
border-radius: 11px;
color: var(--on-accent);
font-family: var(--font-sans);
font-size: 14.5px;
font-weight: 600;
cursor: pointer;
}
.btn-compare {
padding: 13px 20px;
background: var(--o05);
border: 1px solid var(--o14);
border-radius: 11px;
color: var(--text2);
font-family: var(--font-sans);
font-size: 14.5px;
font-weight: 500;
cursor: pointer;
}
.lock-card__note {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted2);
margin-top: 18px;
}
</style>
@@ -0,0 +1,301 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Per-article SEO overrides inside the FAQ editor: URL slug, the preserved URL
path for a migrated article, meta title and meta description. Collapsed by
default — every field is optional, and leaving one empty keeps the value the
public page derives from the question and answer.
-->
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = withDefaults(
defineProps<{
slug?: string
urlPath?: string
metaTitle?: string
metaDescription?: string
/** Shown as the placeholder for the title, so the derived default is visible. */
question?: string
}>(),
{ slug: '', urlPath: '', metaTitle: '', metaDescription: '', question: '' },
)
const emit = defineEmits<{
'update:slug': [value: string]
'update:urlPath': [value: string]
'update:metaTitle': [value: string]
'update:metaDescription': [value: string]
}>()
// Hard caps mirror the backend column widths in app/models/faq.py, which is the
// source of truth — the API rejects anything longer with a 422, so these only
// stop the user typing past the limit. The "recommended" numbers are softer:
// where search engines start truncating the tag in results.
const MAX_SLUG = 80
const MAX_URL_PATH = 400
const MAX_TITLE = 120
const MAX_DESCRIPTION = 300
const RECOMMENDED_TITLE = 60
const RECOMMENDED_DESCRIPTION = 160
// A customised article opens the section on mount, so an override is never
// hidden behind a collapsed panel — but it stays a plain toggle afterwards, so
// the user can still fold it away. The editor mounts fresh per article, which
// is what makes the initial value correct.
const hasValues = computed(
() => Boolean(props.urlPath.trim() || props.metaTitle.trim() || props.metaDescription.trim()),
)
const expanded = ref(hasValues.value)
const titleCount = computed(() => props.metaTitle.trim().length)
const descriptionCount = computed(() => props.metaDescription.trim().length)
function onInput(event: Event): string {
return (event.target as HTMLInputElement | HTMLTextAreaElement).value
}
</script>
<template>
<div class="seo">
<button class="seo__toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
<svg
class="seo__chevron"
:class="{ 'seo__chevron--open': expanded }"
viewBox="0 0 24 24"
width="13"
height="13"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 6l6 6-6 6" />
</svg>
SEO 与 URL
<span v-if="hasValues" class="seo__badge">已自定义</span>
</button>
<div v-if="expanded" class="seo__fields">
<label class="seo__field">
<span class="seo__label">URL 别名</span>
<span class="seo__slug">
<span class="seo__prefix">/a/</span>
<input
type="text"
class="seo__input seo__input--slug"
:maxlength="MAX_SLUG"
placeholder="根据问题自动生成"
:value="slug"
@input="emit('update:slug', onInput($event))"
/>
</span>
<span class="seo__hint">
修改后,指向该文章的现有链接将失效。空格和符号会转换为连字符。
</span>
</label>
<label class="seo__field">
<span class="seo__label">自定义 URL 路径</span>
<input
type="text"
class="seo__input seo__input--path"
:maxlength="MAX_URL_PATH"
placeholder="/hc/en-us/articles/360012-reset-password"
:value="urlPath"
@input="emit('update:urlPath', onInput($event))"
/>
<span class="seo__hint">
将这篇文章发布到它在其他帮助中心已有的 URL 上,以保留搜索排名和外部链接。粘贴旧 URL 或路径即可,留空则使用上方的别名。
</span>
</label>
<label class="seo__field">
<span class="seo__label">
Meta 标题
<span class="seo__count" :class="{ 'seo__count--over': titleCount > RECOMMENDED_TITLE }">
{{ titleCount }}/{{ RECOMMENDED_TITLE }}
</span>
</span>
<input
type="text"
class="seo__input"
:maxlength="MAX_TITLE"
:placeholder="question || '默认为问题内容'"
:value="metaTitle"
@input="emit('update:metaTitle', onInput($event))"
/>
</label>
<label class="seo__field">
<span class="seo__label">
Meta 描述
<span
class="seo__count"
:class="{ 'seo__count--over': descriptionCount > RECOMMENDED_DESCRIPTION }"
>
{{ descriptionCount }}/{{ RECOMMENDED_DESCRIPTION }}
</span>
</span>
<textarea
class="seo__input seo__textarea"
rows="2"
:maxlength="MAX_DESCRIPTION"
placeholder="默认为回答的开头部分"
:value="metaDescription"
@input="emit('update:metaDescription', onInput($event))"
></textarea>
</label>
</div>
</div>
</template>
<style scoped>
.seo {
margin-top: 10px;
border-top: 1px solid var(--o08);
padding-top: 10px;
}
.seo__toggle {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0;
background: none;
border: none;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.08em;
color: var(--muted);
}
.seo__toggle:hover {
color: var(--text);
}
.seo__chevron {
transition: transform 0.15s ease;
}
.seo__chevron--open {
transform: rotate(90deg);
}
.seo__badge {
padding: 2px 7px;
border-radius: 999px;
background: var(--o08);
font-size: 9.5px;
letter-spacing: 0.06em;
color: var(--muted2);
}
.seo__fields {
display: grid;
gap: 12px;
margin-top: 12px;
}
.seo__field {
display: block;
}
.seo__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 5px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.seo__count {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 400;
color: var(--muted2);
}
.seo__count--over {
color: var(--c-coral);
}
.seo__slug {
display: flex;
align-items: center;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 9px;
overflow: hidden;
}
.seo__prefix {
padding: 0 2px 0 11px;
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--muted2);
}
.seo__input {
width: 100%;
box-sizing: border-box;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 9px;
padding: 9px 11px;
color: var(--text);
font-family: var(--font-sans);
font-size: 13px;
outline: none;
}
.seo__input:focus {
border-color: var(--o20);
}
/* The slug input sits inside the prefixed shell, which draws the border. */
.seo__input--slug {
border: none;
border-radius: 0;
padding-left: 0;
font-family: var(--font-mono);
font-size: 12.5px;
}
/* A URL, so it reads in the same monospace as the slug — but it keeps the
standard input shell, since it has no "/a/" prefix to sit behind. */
.seo__input--path {
font-family: var(--font-mono);
font-size: 12.5px;
}
.seo__textarea {
resize: vertical;
line-height: 1.5;
}
.seo__hint {
display: block;
margin-top: 5px;
font-size: 11px;
color: var(--muted2);
}
</style>
@@ -0,0 +1,293 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import Modal from '@/components/common/Modal.vue'
import type { GenerateEstimate, GenerationSource } from '@/types/faq'
const props = defineProps<{
open: boolean
estimate: GenerateEstimate | null
submitting?: boolean
}>()
const emit = defineEmits<{
close: []
generate: [knowledgeIds: number[]]
}>()
const sources = computed<GenerationSource[]>(() => props.estimate?.sources ?? [])
const selected = ref<Set<number>>(new Set())
// Default selection = sources without FAQs (the "new" ones). Seed only when the
// picker OPENS (not on later estimate changes) so a background refetch can't
// wipe the user's manual selection mid-interaction.
watch(
() => props.open,
(open) => {
if (!open) return
const next = new Set<number>()
for (const s of sources.value) if (!s.has_faqs) next.add(s.id)
// If everything already has FAQs, start from all so the user can regenerate.
if (next.size === 0) for (const s of sources.value) next.add(s.id)
selected.value = next
},
{ immediate: true },
)
function toggle(id: number) {
const next = new Set(selected.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selected.value = next
}
const allSelected = computed(
() => sources.value.length > 0 && selected.value.size === sources.value.length,
)
function toggleAll() {
selected.value = allSelected.value ? new Set() : new Set(sources.value.map((s) => s.id))
}
const metered = computed(() => props.estimate?.metered ?? false)
const remaining = computed(() => props.estimate?.remaining_credits ?? null)
const selectedCredits = computed(() =>
sources.value.reduce((n, s) => (selected.value.has(s.id) ? n + s.estimated_calls : n), 0),
)
const overBudget = computed(
() => metered.value && remaining.value !== null && selectedCredits.value > remaining.value,
)
const canGenerate = computed(() => selected.value.size > 0 && !props.submitting && !overBudget.value)
function labelFor(s: GenerationSource): string {
const pages = s.pages ? `${s.pages} 个页面` : ''
return pages
}
function submit() {
if (!canGenerate.value) return
emit('generate', [...selected.value])
}
</script>
<template>
<Modal v-if="open" @close="$emit('close')">
<template #title>生成常见问题</template>
<template #content>
<p class="picker-sub">选择要从哪些知识来源生成常见问题。</p>
<div class="picker-toolbar">
<button class="link-btn" type="button" @click="toggleAll">
{{ allSelected ? '取消全选' : '全选' }}
</button>
<span class="picker-count">已选 {{ selected.size }} / {{ sources.length }}</span>
</div>
<div class="picker-list">
<label
v-for="s in sources"
:key="s.id"
class="src"
:class="{ 'src--on': selected.has(s.id) }"
>
<input class="src__check" type="checkbox" :checked="selected.has(s.id)" @change="toggle(s.id)" />
<span class="src__body">
<span class="src__name" :title="s.name">{{ s.name }}</span>
<span class="src__meta">
<span class="src__type">{{ s.source_type }}</span>
<template v-if="labelFor(s)"> · {{ labelFor(s) }}</template>
<span v-if="s.has_faqs" class="src__badge">已生成</span>
</span>
</span>
</label>
</div>
<p v-if="metered" class="picker-credits" :class="{ 'picker-credits--over': overBudget }">
预计消耗约 {{ selectedCredits }} 条额度<template v-if="remaining !== null"> · 剩余 {{ remaining }} 条</template>
<template v-if="overBudget">——额度不足,请升级套餐或改用你自己的 AI 模型。</template>
</p>
<div class="picker-actions">
<button class="btn-cancel" type="button" @click="$emit('close')">取消</button>
<button class="btn-generate" type="button" :disabled="!canGenerate" @click="submit">
{{ submitting ? '正在启动…' : `从 ${selected.size} 个来源生成` }}
</button>
</div>
</template>
</Modal>
</template>
<style scoped>
.picker-sub {
font-size: 13.5px;
color: var(--muted);
margin: -12px 0 16px;
line-height: 1.5;
}
.picker-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.link-btn {
background: transparent;
border: none;
color: var(--c-purple);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.picker-count {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--muted2);
}
.picker-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 42vh;
overflow-y: auto;
margin-bottom: 16px;
}
.src {
display: flex;
align-items: flex-start;
gap: 11px;
padding: 12px 13px;
background: var(--bg);
border: 1px solid var(--o10);
border-radius: 11px;
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.src--on {
border-color: var(--purple-border);
background: var(--purple-bg);
}
.src__check {
width: 16px;
height: 16px;
margin: 1px 0 0;
flex-shrink: 0;
accent-color: var(--c-purple);
cursor: pointer;
}
.src__body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.src__name {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.src__meta {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 12px;
color: var(--muted);
}
.src__type {
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.src__badge {
padding: 1px 7px;
border-radius: 6px;
background: var(--o05);
border: 1px solid var(--o12);
font-size: 10.5px;
color: var(--muted);
}
.picker-credits {
font-size: 12.5px;
color: var(--muted);
margin: 0 0 16px;
line-height: 1.5;
}
.picker-credits--over {
color: var(--c-coral);
}
.picker-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.btn-cancel {
padding: 11px 18px;
background: transparent;
border: 1px solid var(--o12);
border-radius: 10px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-generate {
padding: 11px 20px;
background: var(--purple-bg);
border: 1px solid var(--purple-border);
border-radius: 10px;
color: var(--c-purple);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.btn-generate:disabled {
opacity: 0.55;
cursor: default;
}
</style>
@@ -0,0 +1,857 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import type { FaqImportMode, FaqItem, FaqStatus } from '@/types/faq'
import { useFaqWorkspace } from '@/composables/useFaqWorkspace'
import { useHelpCenterSettings } from '@/composables/useHelpCenterSettings'
import FaqGenerateBar from './FaqGenerateBar.vue'
import FaqEmptyState from './FaqEmptyState.vue'
import FaqGenerationProgress from './FaqGenerationProgress.vue'
import FaqCategoryGroup from './FaqCategoryGroup.vue'
import FaqCard from './FaqCard.vue'
import FaqImportModal from './FaqImportModal.vue'
import FaqSourcePicker from './FaqSourcePicker.vue'
import HelpCenterAppearance from './HelpCenterAppearance.vue'
import HelpCenterPublic from './HelpCenterPublic.vue'
const props = defineProps<{ organizationId?: string }>()
const emit = defineEmits<{
'plan-allowed': [allowed: boolean]
}>()
const {
faqs,
job,
settings,
estimate,
fetchEstimate,
sourceCount,
pageCount,
phase,
barPhase,
publishedCount,
groupedFaqs,
searchQuery,
categoryFilter,
statusFilter,
categories,
hasActiveFilters,
filteredCount,
resetFilters,
toggleCategory,
isCategoryOpen,
editingId,
isNewFaq,
draftQuestion,
draftAnswer,
draftCategory,
draftSlug,
draftUrlPath,
draftMetaTitle,
draftMetaDescription,
isSaving,
selectedIds,
selectionActive,
toggleSelect,
setSelected,
clearSelection,
bulkSetStatus,
bulkDelete,
refresh,
startPolling,
stopPolling,
startGeneration,
submitImport,
submitPdfImport,
togglePublish,
startEdit,
startAdd,
cancelEdit,
saveEdit,
deleteFaq,
} = useFaqWorkspace(() => props.organizationId)
const {
saveState,
queueSave,
saveNow,
uploadLogo,
removeLogo,
uploadFavicon,
removeFavicon,
domainBusy,
setDomain,
verifyDomain,
removeDomain,
} = useHelpCenterSettings(settings)
// Surface the server-side plan verdict to the view's lock overlay.
watch(
() => settings.value?.plan_allowed,
(allowed) => {
if (typeof allowed === 'boolean') emit('plan-allowed', allowed)
},
)
defineExpose({ settings })
// Tabs keep the (potentially long) FAQ list and the settings on separate
// panes so neither forces the other into a long scroll.
const tab = ref<'faqs' | 'settings'>('faqs')
// Import modal state.
const importOpen = ref(false)
const importSubmitting = ref(false)
// Knowledge-source picker (shown when there are multiple sources).
const sourcePickerOpen = ref(false)
const generating = ref(false)
async function onPickSources(knowledgeIds: number[]) {
generating.value = true
try {
// Keep the picker open if the job didn't start (409/budget) — the error
// toast explains why and the user can adjust their selection.
if (await startGeneration(knowledgeIds)) sourcePickerOpen.value = false
} finally {
generating.value = false
}
}
async function onImportSubmit(url: string, mode: FaqImportMode, preserveUrls: boolean) {
importSubmitting.value = true
try {
const ok = await submitImport(url, mode, preserveUrls)
if (ok) importOpen.value = false
} finally {
importSubmitting.value = false
}
}
async function onPdfImportSubmit(file: File) {
importSubmitting.value = true
try {
const ok = await submitPdfImport(file)
if (ok) importOpen.value = false
} finally {
importSubmitting.value = false
}
}
// A single reusable confirm dialog for deletes and generation.
interface ConfirmState {
title: string
message: string
actionLabel: string
busyLabel: string
intent?: 'danger' | 'primary'
disabledReason?: string
action: () => Promise<void>
}
const confirmState = ref<ConfirmState | null>(null)
const confirmBusy = ref(false)
function askDelete(faq: FaqItem) {
confirmState.value = {
title: '删除常见问题',
message: `删除“${faq.question}”?此操作无法撤销。`,
actionLabel: '删除',
busyLabel: '正在删除…',
intent: 'danger',
action: () => deleteFaq(faq),
}
}
async function askGenerate() {
await fetchEstimate(true) // full estimate incl. page counts for the dialog
const e = estimate.value
if (!e) {
// Estimate unavailable (e.g. plan check failed) — let the backend decide.
await startGeneration()
return
}
if (e.total_sources === 0) {
toast.error('没有可用于生成的知识来源,请先添加知识。')
return
}
// Multiple sources → let the user pick which to generate from.
if (e.sources.length > 1) {
sourcePickerOpen.value = true
return
}
if (e.new_sources === 0) {
toast.info('所有知识来源都已生成常见问题,没有新内容可生成。')
return
}
const sources = `${e.new_sources} 个新来源`
const pages = e.pages ? `(约 ${e.pages} 个页面)` : ''
let message = `根据 ${sources}${pages}生成常见问题?`
let disabledReason: string | undefined
if (e.metered) {
message += ` 预计消耗约 ${e.estimated_calls} 条消息额度。`
if (e.remaining_credits !== null && e.estimated_calls > e.remaining_credits) {
disabledReason = `本周期仅剩 ${e.remaining_credits} 条额度——请升级套餐或改用你自己的 AI 模型。`
}
}
confirmState.value = {
title: '生成常见问题',
message,
actionLabel: '生成',
busyLabel: '正在启动…',
intent: 'primary',
disabledReason,
action: async () => {
await startGeneration()
},
}
}
function askBulkDelete() {
const n = selectedIds.value.size
confirmState.value = {
title: `删除 ${n} 条常见问题`,
message: '已发布的文章将从帮助中心移除,此操作无法撤销。',
actionLabel: '删除',
busyLabel: '正在删除…',
intent: 'danger',
action: async () => {
try {
await bulkDelete()
} catch (error: any) {
toast.error(error.message)
}
},
}
}
function selectedInCategory(items: FaqItem[]): number {
return items.reduce((n, item) => n + (selectedIds.value.has(item.id) ? 1 : 0), 0)
}
function publishedInCategory(items: FaqItem[]): number {
return items.reduce((n, item) => n + (item.status === 'published' ? 1 : 0), 0)
}
const STATUS_FILTERS: { v: 'all' | FaqStatus; l: string }[] = [
{ v: 'all', l: '全部' },
{ v: 'published', l: '已发布' },
{ v: 'draft', l: '草稿' },
]
async function runConfirm() {
const state = confirmState.value
if (!state) return
confirmBusy.value = true
try {
await state.action()
} finally {
confirmBusy.value = false
confirmState.value = null
}
}
// Placeholder card for a manually added FAQ. The category mirrors the server
// default applied when the topic field is left blank.
const newFaqStub = computed<FaqItem>(() => ({
id: 'new',
question: '',
answer: '',
category: 'General',
status: 'draft',
knowledge_id: null,
source_label: '手动添加',
}))
onMounted(() => {
refresh()
startPolling()
})
onUnmounted(stopPolling)
</script>
<template>
<div class="faq-workspace">
<div v-if="phase === 'loading'" class="loading">正在加载常见问题…</div>
<template v-else>
<nav class="tabs" role="tablist">
<button class="tab" :class="{ 'tab--active': tab === 'faqs' }" type="button" role="tab" :aria-selected="tab === 'faqs'" @click="tab = 'faqs'">
常见问题
<span v-if="faqs.length" class="tab__count">{{ faqs.length }}</span>
</button>
<button class="tab" :class="{ 'tab--active': tab === 'settings' }" type="button" role="tab" :aria-selected="tab === 'settings'" @click="tab = 'settings'">
自定义
</button>
</nav>
<div v-show="tab === 'faqs'" class="tab-panel">
<FaqGenerateBar
class="workspace-bar"
:phase="barPhase"
:source-count="sourceCount"
:page-count="pageCount"
:faq-count="faqs.length"
:published-count="publishedCount"
:new-source-count="estimate?.new_sources ?? null"
@generate="askGenerate"
@import="importOpen = true"
@add="startAdd"
/>
<FaqEmptyState
v-if="phase === 'empty' && !isNewFaq"
:source-count="sourceCount"
:page-count="pageCount"
@generate="askGenerate"
@import="importOpen = true"
@add="startAdd"
/>
<FaqGenerationProgress v-else-if="phase === 'generating' && job" :job="job" />
<template v-else>
<div v-if="isNewFaq" class="faq-list faq-list--new">
<FaqCard
:faq="newFaqStub"
:editing="true"
:is-new="true"
:saving="isSaving"
:categories="categories"
v-model:draft-question="draftQuestion"
v-model:draft-answer="draftAnswer"
v-model:draft-category="draftCategory"
v-model:draft-slug="draftSlug"
v-model:draft-url-path="draftUrlPath"
v-model:draft-meta-title="draftMetaTitle"
v-model:draft-meta-description="draftMetaDescription"
@save="saveEdit"
@cancel="cancelEdit"
/>
</div>
<!-- Search + filter toolbar (only once there are FAQs to filter) -->
<template v-if="faqs.length">
<div class="faq-toolbar">
<div class="faq-search">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" /></svg>
<input v-model="searchQuery" type="search" placeholder="搜索问题与答案…" aria-label="搜索常见问题" />
</div>
<select v-model="categoryFilter" class="faq-select" aria-label="按分类筛选">
<option :value="null">全部分类</option>
<option v-for="c in categories" :key="c" :value="c">{{ c }}</option>
</select>
<div class="faq-status-filter" role="group" aria-label="按状态筛选">
<button
v-for="opt in STATUS_FILTERS"
:key="opt.v"
class="faq-status-filter__btn"
:class="{ 'faq-status-filter__btn--active': statusFilter === opt.v }"
type="button"
@click="statusFilter = opt.v"
>{{ opt.l }}</button>
</div>
</div>
<div v-if="hasActiveFilters" class="faq-resultcount">显示 {{ filteredCount }} / {{ faqs.length }} 条</div>
</template>
<div v-if="faqs.length && groupedFaqs.size === 0" class="faq-noresults">
<p>没有符合筛选条件的常见问题。</p>
<button v-if="hasActiveFilters" class="faq-noresults__clear" type="button" @click="resetFilters">清除筛选</button>
</div>
<div v-else-if="groupedFaqs.size" class="faq-list">
<FaqCategoryGroup
v-for="[category, items] in groupedFaqs"
:key="category"
:category="category"
:count="items.length"
:published-count="publishedInCategory(items)"
:selected-count="selectedInCategory(items)"
:open="isCategoryOpen(category)"
@toggle="toggleCategory(category)"
@toggle-all="setSelected(items, $event)"
>
<FaqCard
v-for="faq in items"
:key="faq.id"
:faq="faq"
:editing="editingId === faq.id"
:saving="isSaving"
:selectable="selectionActive"
:selected="selectedIds.has(faq.id)"
:categories="categories"
:draft-question="draftQuestion"
:draft-answer="draftAnswer"
:draft-category="draftCategory"
:draft-slug="draftSlug"
:draft-url-path="draftUrlPath"
:draft-meta-title="draftMetaTitle"
:draft-meta-description="draftMetaDescription"
@update:draft-question="draftQuestion = $event"
@update:draft-answer="draftAnswer = $event"
@update:draft-category="draftCategory = $event"
@update:draft-slug="draftSlug = $event"
@update:draft-url-path="draftUrlPath = $event"
@update:draft-meta-title="draftMetaTitle = $event"
@update:draft-meta-description="draftMetaDescription = $event"
@toggle-select="toggleSelect(faq.id)"
@toggle-status="togglePublish(faq)"
@edit="startEdit(faq)"
@delete="askDelete(faq)"
@save="saveEdit"
@cancel="cancelEdit"
/>
</FaqCategoryGroup>
</div>
</template>
</div>
<div v-show="tab === 'settings'" class="tab-panel">
<template v-if="settings">
<HelpCenterAppearance
class="settings-section"
:settings="settings"
:save-state="saveState"
@update="queueSave"
@save-now="saveNow"
@upload-logo="uploadLogo"
@remove-logo="removeLogo"
@upload-favicon="uploadFavicon"
@remove-favicon="removeFavicon"
/>
<HelpCenterPublic
class="settings-section"
:settings="settings"
:domain-busy="domainBusy"
@toggle-enabled="saveNow({ enabled: $event })"
@update-ai="saveNow($event)"
@set-domain="setDomain"
@verify-domain="verifyDomain"
@remove-domain="removeDomain"
/>
</template>
<div v-else class="loading">正在加载设置…</div>
</div>
</template>
<FaqImportModal :open="importOpen" :submitting="importSubmitting" @close="importOpen = false" @submit="onImportSubmit" @submit-pdf="onPdfImportSubmit" />
<FaqSourcePicker
:open="sourcePickerOpen"
:estimate="estimate"
:submitting="generating"
@close="sourcePickerOpen = false"
@generate="onPickSources"
/>
<div v-if="selectionActive" class="bulkbar" role="toolbar" aria-label="批量操作">
<span class="bulkbar__count">已选 {{ selectedIds.size }} 条</span>
<button class="bulkbar__btn" type="button" @click="bulkSetStatus('published')">发布</button>
<button class="bulkbar__btn" type="button" @click="bulkSetStatus('draft')">取消发布</button>
<button class="bulkbar__btn bulkbar__btn--danger" type="button" @click="askBulkDelete">删除</button>
<button class="bulkbar__clear" type="button" aria-label="清除选择" @click="clearSelection">✕</button>
</div>
<div v-if="confirmState" class="confirm" role="dialog" aria-modal="true">
<div class="confirm__card">
<h3 class="confirm__title">{{ confirmState.title }}</h3>
<p class="confirm__msg">{{ confirmState.message }}</p>
<p v-if="confirmState.disabledReason" class="confirm__blocked">{{ confirmState.disabledReason }}</p>
<div class="confirm__actions">
<button class="confirm__cancel" type="button" :disabled="confirmBusy" @click="confirmState = null">取消</button>
<button
class="confirm__go"
:class="confirmState.intent === 'primary' ? 'confirm__go--primary' : 'confirm__go--danger'"
type="button"
:disabled="confirmBusy || !!confirmState.disabledReason"
@click="runConfirm"
>
{{ confirmBusy ? confirmState.busyLabel : confirmState.actionLabel }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.faq-workspace {
display: flex;
flex-direction: column;
}
.loading {
padding: 48px 0;
text-align: center;
font-size: 14px;
color: var(--muted);
}
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--o08);
margin-bottom: 20px;
}
.tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.tab:hover {
color: var(--text);
}
.tab--active {
color: var(--text);
border-bottom-color: var(--c-teal);
}
.tab__count {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
padding: 1px 7px;
border-radius: var(--radius-pill);
background: var(--o08);
color: var(--muted2);
}
.tab--active .tab__count {
background: var(--teal-bg);
color: var(--c-teal);
}
.workspace-bar {
margin-bottom: 16px;
}
.faq-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.faq-list--new {
margin-bottom: 16px;
}
.faq-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.faq-search {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
min-width: 200px;
padding: 0 12px;
background: var(--surface);
border: 1px solid var(--o12);
border-radius: 11px;
color: var(--muted);
}
.faq-search input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: var(--font-sans);
font-size: 13.5px;
padding: 10px 0;
}
.faq-select {
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--o12);
border-radius: 11px;
color: var(--text2);
font-family: var(--font-sans);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
}
.faq-status-filter {
display: inline-flex;
padding: 3px;
background: var(--o05);
border: 1px solid var(--o12);
border-radius: 11px;
}
.faq-status-filter__btn {
padding: 7px 13px;
background: transparent;
border: none;
border-radius: 8px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.faq-status-filter__btn--active {
background: var(--surface);
color: var(--text2);
box-shadow: 0 1px 3px var(--scrim);
}
.faq-resultcount {
margin: -8px 0 14px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted2);
}
.faq-noresults {
padding: 40px 0;
text-align: center;
color: var(--muted);
font-size: 14px;
}
.faq-noresults__clear {
margin-top: 12px;
padding: 8px 16px;
background: var(--o05);
border: 1px solid var(--o12);
border-radius: 10px;
color: var(--text2);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.settings-section:not(:first-child) {
margin-top: 36px;
}
.bulkbar {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 90;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--surface);
border: 1px solid var(--o14);
border-radius: var(--radius-pill);
box-shadow: 0 8px 30px var(--scrim);
}
.bulkbar__count {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted);
padding: 0 6px;
}
.bulkbar__btn {
padding: 8px 14px;
background: var(--o05);
border: 1px solid var(--o12);
border-radius: var(--radius-pill);
color: var(--text2);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.bulkbar__btn:hover {
background: var(--o08);
}
.bulkbar__btn--danger {
background: var(--coral-bg);
border-color: var(--coral-border);
color: var(--c-coral);
}
.bulkbar__clear {
width: 30px;
height: 30px;
background: transparent;
border: none;
border-radius: 50%;
color: var(--muted);
font-size: 13px;
cursor: pointer;
}
.bulkbar__clear:hover {
background: var(--o08);
color: var(--text);
}
.confirm {
position: fixed;
inset: 0;
z-index: 100;
background: var(--scrim);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.confirm__card {
background: var(--surface);
border: 1px solid var(--o12);
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 100%;
}
.confirm__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 18px;
color: var(--text);
margin: 0 0 8px;
}
.confirm__msg {
font-size: 14px;
color: var(--muted);
line-height: 1.55;
margin: 0 0 20px;
}
.confirm__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.confirm__cancel {
padding: 10px 16px;
background: transparent;
border: 1px solid var(--o12);
border-radius: 10px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
}
.confirm__go {
padding: 10px 18px;
border-radius: 10px;
font-family: var(--font-sans);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
}
.confirm__go:disabled {
opacity: 0.55;
cursor: default;
}
.confirm__go--danger {
background: var(--coral-bg);
border: 1px solid var(--coral-border);
color: var(--c-coral);
}
.confirm__go--primary {
background: var(--purple-bg);
border: 1px solid var(--purple-border);
color: var(--c-purple);
}
.confirm__blocked {
font-size: 13px;
color: var(--c-coral);
background: var(--coral-bg);
border: 1px solid var(--coral-border);
border-radius: 10px;
padding: 10px 12px;
margin: -8px 0 20px;
}
/* ── Mobile ─────────────────────────────────────────────────────────────
The toolbar wrapped into a ragged column: the search box stretched but the
select and the status group sat at content width, so the select's arrow
landed mid-row instead of at the edge. Each control takes the full row. */
@media (max-width: 768px) {
.faq-toolbar {
gap: 8px;
}
.faq-search,
.faq-select,
.faq-status-filter {
flex: 1 0 100%;
min-width: 0;
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
min-height: 44px;
}
/* 16px keeps iOS from zooming the page when either field takes focus */
.faq-search input,
.faq-select {
font-size: 16px;
}
.faq-select {
width: 100%;
padding-right: 12px;
}
.faq-status-filter__btn {
flex: 1;
min-height: 38px;
font-size: 14px;
}
}
</style>
@@ -0,0 +1,592 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import type { HelpCenterSettings, HelpCenterSettingsUpdate } from '@/types/faq'
import type { SaveState } from '@/composables/useHelpCenterSettings'
import HelpCenterLogoField from './HelpCenterLogoField.vue'
import HelpCenterPreview from './HelpCenterPreview.vue'
const props = defineProps<{
settings: HelpCenterSettings
saveState: SaveState
}>()
const emit = defineEmits<{
update: [payload: HelpCenterSettingsUpdate]
'save-now': [payload: HelpCenterSettingsUpdate]
'upload-logo': [file: File]
'remove-logo': []
'upload-favicon': [file: File]
'remove-favicon': []
}>()
const BRAND_SWATCHES = ['#4338CA', '#0E8C8C', '#CF5B38', '#6D5BD0', '#1F8A5B', '#2A6FDB']
// Custom brand color: native picker + hex field, both saving immediately.
const isPreset = computed(() =>
BRAND_SWATCHES.some((c) => c.toLowerCase() === (props.settings.brand_color || '').toLowerCase()),
)
const hexDraft = ref(props.settings.brand_color)
watch(() => props.settings.brand_color, (v) => { hexDraft.value = v })
// The native <input type=color> only accepts #rrggbb — expand 3-digit and drop
// alpha from 8-digit so opening the picker starts on the real color, never
// silently resetting to black.
const pickerValue = computed(() => {
const body = (props.settings.brand_color || '').replace(/^#/, '')
if (body.length === 3) return `#${[...body].map((c) => c + c).join('')}`
if (body.length >= 6) return `#${body.slice(0, 6)}`
return '#000000'
})
function saveColor(value: string) {
if (value.toLowerCase() !== (props.settings.brand_color || '').toLowerCase()) {
emit('save-now', { brand_color: value })
}
}
function onPicker(event: Event) {
saveColor((event.target as HTMLInputElement).value.toUpperCase())
}
function commitHex() {
const raw = hexDraft.value.trim()
const body = raw.replace(/^#/, '')
// Accept 3, 6 or 8 hex digits (matches the backend validator).
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$|^[0-9a-fA-F]{8}$/.test(body)) {
toast.error('请输入类似 #4338CA 的十六进制颜色')
hexDraft.value = props.settings.brand_color
return
}
const hex = `#${body.toUpperCase()}`
hexDraft.value = hex
saveColor(hex)
}
const saveStateLabel = computed(() => {
if (props.saveState === 'saving') return '保存中…'
if (props.saveState === 'saved') return '已保存'
if (props.saveState === 'error') return '保存失败,再次编辑可重试'
return ''
})
/** Text fields: mutate in place for instant feedback, emit for debounced save. */
function onText(field: 'cta_text' | 'cta_url', event: Event) {
const value = (event.target as HTMLInputElement).value
props.settings[field] = value
emit('update', { [field]: value })
}
// Header link rows are mutated in place — the settings engine deep-watches
// header_links and debounce-saves.
function addLink() {
props.settings.header_links.push({ label: '新链接', url: '' })
}
function removeLink(index: number) {
props.settings.header_links.splice(index, 1)
}
</script>
<template>
<section class="appearance">
<div class="section-head">
<h2 class="section-head__title">帮助中心外观</h2>
<span class="section-head__sub">Logo、颜色与页头链接</span>
<span v-if="saveStateLabel" class="section-head__save" :class="`is-${saveState}`">{{ saveStateLabel }}</span>
</div>
<div class="appearance__card">
<!-- controls -->
<div class="controls">
<div>
<label class="mono-label">Logo</label>
<HelpCenterLogoField
:image-url="settings.logo_url"
kind="logo"
@upload="$emit('upload-logo', $event)"
@remove="$emit('remove-logo')"
/>
</div>
<div>
<label class="mono-label">Favicon</label>
<HelpCenterLogoField
:image-url="settings.favicon_url"
kind="favicon"
:max-bytes="1048576"
:output-max="128"
square
@upload="$emit('upload-favicon', $event)"
@remove="$emit('remove-favicon')"
/>
</div>
<div>
<label class="mono-label">品牌色</label>
<div class="swatches">
<button
v-for="color in BRAND_SWATCHES"
:key="color"
class="swatch"
:class="{ 'swatch--selected': settings.brand_color.toLowerCase() === color.toLowerCase() }"
:style="{ background: color, boxShadow: settings.brand_color.toLowerCase() === color.toLowerCase() ? `0 0 0 2px ${color}` : 'none' }"
type="button"
:title="color"
@click="$emit('save-now', { brand_color: color })"
></button>
<!-- Custom color: swatch opens the OS picker; shows current color when off-preset. -->
<label
class="swatch swatch--custom"
:class="{ 'swatch--selected': !isPreset }"
:style="!isPreset ? { background: settings.brand_color, boxShadow: `0 0 0 2px ${settings.brand_color}` } : {}"
title="自定义颜色"
>
<input type="color" class="swatch__picker" :value="pickerValue" @change="onPicker" />
<svg v-if="isPreset" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</label>
</div>
<div class="hex-field">
<span class="hex-field__hash">#</span>
<input
:value="hexDraft.replace(/^#/, '')"
maxlength="8"
spellcheck="false"
placeholder="4338CA"
aria-label="品牌色十六进制色值"
@input="hexDraft = ($event.target as HTMLInputElement).value"
@keydown.enter="commitHex"
@blur="commitHex"
/>
</div>
<p class="hint">用于重新着色帮助中心的按钮、链接、搜索框和高亮元素。</p>
</div>
<div>
<label class="mono-label">页头链接</label>
<p class="hint hint--tight">显示在帮助中心页头的链接——可指向你的官网。</p>
<div class="link-rows">
<div v-for="(link, index) in settings.header_links" :key="index" class="link-row">
<input v-model="link.label" class="text-input text-input--label" type="text" placeholder="名称" />
<div class="url-input">
<span class="url-input__prefix">https://</span>
<input v-model="link.url" type="text" placeholder="yoursite.com" />
</div>
<button class="remove-btn" type="button" title="移除" @click="removeLink(index)">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
</div>
<button class="add-link" type="button" @click="addLink">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14" /></svg>
添加链接
</button>
<div class="divider"></div>
<div class="row-head">
<label class="mono-label mono-label--inline">主按钮</label>
<label class="switch switch--sm">
<input type="checkbox" :checked="settings.cta_enabled" @change="$emit('save-now', { cta_enabled: ($event.target as HTMLInputElement).checked })" />
<span class="switch__track"><span class="switch__knob"></span></span>
</label>
</div>
<div class="link-row" :class="{ 'link-row--off': !settings.cta_enabled }">
<input class="text-input text-input--label" type="text" placeholder="名称" :disabled="!settings.cta_enabled" :value="settings.cta_text || ''" @input="onText('cta_text', $event)" />
<div class="url-input">
<span class="url-input__prefix">https://</span>
<input type="text" placeholder="app.yoursite.com" :disabled="!settings.cta_enabled" :value="settings.cta_url || ''" @input="onText('cta_url', $event)" />
</div>
</div>
</div>
</div>
<!-- live preview -->
<div class="preview-col">
<label class="mono-label">实时预览</label>
<HelpCenterPreview :settings="settings" />
<p class="preview-note">
<span class="preview-note__url">{{ settings.live_url }}</span>
上的修改会立即应用到已发布的帮助中心。
</p>
</div>
</div>
</section>
</template>
<style scoped>
.section-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 16px;
}
.section-head__title {
font-family: var(--font-display);
font-weight: 600;
font-size: 19px;
letter-spacing: -0.01em;
color: var(--text);
margin: 0;
}
.section-head__sub,
.section-head__save {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted2);
}
.section-head__save {
margin-left: auto;
}
.section-head__save.is-saved {
color: var(--c-teal);
}
.section-head__save.is-error {
color: var(--c-coral);
}
.appearance__card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 18px;
padding: 22px;
display: grid;
grid-template-columns: 1fr 1.05fr;
gap: 26px;
align-items: start;
}
@media (max-width: 900px) {
.appearance__card {
grid-template-columns: 1fr;
}
}
.controls {
display: flex;
flex-direction: column;
gap: 24px;
min-width: 0;
}
.mono-label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted2);
margin-bottom: 10px;
}
.swatches {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.swatch {
width: 36px;
height: 36px;
border-radius: 10px;
cursor: pointer;
padding: 0;
border: 2px solid transparent;
}
.swatch--selected {
border-color: var(--text);
}
.swatch--custom {
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--o05);
border: 2px dashed var(--o25);
color: var(--muted);
}
.swatch--custom.swatch--selected {
border-style: solid;
color: #fff;
}
/* The native color input fills the swatch but stays invisible — the label is
the visible control and clicking anywhere opens the OS picker. */
.swatch__picker {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: none;
padding: 0;
cursor: pointer;
}
.hex-field {
display: inline-flex;
align-items: center;
margin-top: 12px;
background: var(--bg);
border: 1px solid var(--o10);
border-radius: 9px;
padding: 0 11px;
font-family: var(--font-mono);
}
.hex-field__hash {
color: var(--faint);
font-size: 13px;
}
.hex-field input {
width: 84px;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
text-transform: uppercase;
padding: 9px 2px 9px 4px;
}
.hint {
font-size: 12.5px;
color: var(--muted);
margin: 11px 0 0;
line-height: 1.5;
}
.hint--tight {
margin: 0 0 12px;
}
.link-rows {
display: flex;
flex-direction: column;
gap: 9px;
margin-bottom: 12px;
}
.link-row {
display: flex;
align-items: center;
gap: 8px;
}
.text-input {
background: var(--bg);
border: 1px solid var(--o10);
border-radius: 9px;
padding: 9px 11px;
font-size: 13px;
font-family: var(--font-sans);
color: var(--text);
outline: none;
}
.text-input--label {
width: 112px;
flex-shrink: 0;
}
.url-input {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
background: var(--bg);
border: 1px solid var(--o10);
border-radius: 9px;
padding: 0 11px;
font-family: var(--font-mono);
}
.url-input__prefix {
color: var(--faint);
font-size: 12.5px;
}
.url-input input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 12.5px;
padding: 9px 3px;
font-family: var(--font-mono);
}
.remove-btn {
width: 32px;
height: 32px;
flex-shrink: 0;
border-radius: 8px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.remove-btn:hover {
background: var(--coral-bg);
color: var(--c-coral);
border-color: var(--coral-border);
}
.add-link {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 14px;
background: var(--o05);
border: 1px dashed var(--o16);
border-radius: 9px;
color: var(--text2);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.add-link:hover {
background: var(--o08);
border-color: var(--o25);
}
.divider {
height: 1px;
background: var(--o07);
margin: 18px 0;
}
.row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.mono-label--inline {
margin-bottom: 0;
}
.link-row--off {
opacity: 0.5;
}
.link-row--off .text-input,
.link-row--off .url-input {
cursor: not-allowed;
}
/* Toggle switch (scoped copy of the shared switch styling). */
.switch {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch__track {
position: relative;
flex-shrink: 0;
width: 38px;
height: 22px;
border-radius: var(--radius-pill);
background: var(--o12);
transition: background 0.18s ease;
}
.switch__knob {
position: absolute;
top: 3px;
left: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.18s ease;
}
.switch input:checked + .switch__track {
background: var(--c-teal);
}
.switch input:checked + .switch__track .switch__knob {
transform: translateX(16px);
}
.switch--sm .switch__track {
width: 34px;
height: 20px;
}
.switch--sm .switch__knob {
width: 14px;
height: 14px;
}
.switch--sm input:checked + .switch__track .switch__knob {
transform: translateX(14px);
}
.preview-col {
min-width: 0;
}
.preview-note {
font-size: 12px;
color: var(--muted2);
margin: 11px 0 0;
line-height: 1.5;
}
.preview-note__url {
font-family: var(--font-mono);
color: var(--text3);
}
</style>
@@ -0,0 +1,503 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import type { HelpCenterDomain } from '@/types/faq'
const props = defineProps<{
domain: HelpCenterDomain | null
busy: boolean
}>()
const emit = defineEmits<{
'set-domain': [domain: string]
'verify-domain': []
'remove-domain': []
}>()
const domainInput = ref(props.domain?.custom_domain || '')
watch(
() => props.domain?.custom_domain,
(value) => {
domainInput.value = value || ''
},
)
const currentDomain = computed(() => props.domain?.custom_domain || '')
const normalizedInput = computed(() => domainInput.value.trim().replace(/^https?:\/\//i, ''))
const isVerified = computed(() => currentDomain.value !== '' && props.domain?.domain_status === 'verified')
// empty → enter a domain · pending → add DNS records + verify · connected → live
const phase = computed<'empty' | 'pending' | 'connected'>(() => {
if (!currentDomain.value) return 'empty'
return isVerified.value ? 'connected' : 'pending'
})
const statusPill = computed(() => {
if (phase.value === 'connected') return { label: '已连接', cls: 'status-pill--teal' }
if (phase.value === 'pending') return { label: '待验证', cls: 'status-pill--warn' }
return { label: '未配置', cls: 'status-pill--idle' }
})
const sslPill = computed(() => {
const status = props.domain?.ssl_status
if (status === 'active') return { label: '已启用', cls: 'record-pill--teal' }
if (status === 'failed') return { label: '失败', cls: 'record-pill--coral' }
return { label: '签发中', cls: 'record-pill--idle' }
})
const liveUrl = computed(() => `https://${currentDomain.value}`)
function addDomain() {
if (!normalizedInput.value || props.busy) return
emit('set-domain', normalizedInput.value)
}
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text)
toast.success('已复制')
} catch {
toast.error('无法复制,请手动选择并复制')
}
}
</script>
<template>
<div>
<div class="domain-head">
<h3 class="domain-head__title">自定义域名</h3>
<span class="status-pill" :class="statusPill.cls">
<span class="status-pill__dot"></span>
{{ statusPill.label }}
</span>
</div>
<!-- Step 1: enter a domain -->
<template v-if="phase === 'empty'">
<p class="domain-copy">
用你自己的子域名提供帮助中心,替代 ChatterMate 的 URL。在下方输入域名,我们会显示需要添加的两条 DNS 记录。
</p>
<div class="domain-form">
<div class="domain-input">
<span class="domain-input__prefix">https://</span>
<input
v-model="domainInput"
type="text"
placeholder="help.yourcompany.com"
@keydown.enter="addDomain"
/>
</div>
<button class="btn-primary" type="button" :disabled="busy || !normalizedInput" @click="addDomain">
{{ busy ? '处理中…' : '添加域名' }}
</button>
</div>
</template>
<!-- Step 2: add the DNS records, then verify -->
<template v-else>
<div class="domain-current">
<div class="domain-current__lead">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M3 12h18" /><path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18z" /></svg>
<a v-if="phase === 'connected'" :href="liveUrl" target="_blank" rel="noopener" class="domain-current__name domain-current__name--link">{{ currentDomain }}</a>
<span v-else class="domain-current__name">{{ currentDomain }}</span>
</div>
<button class="link-btn" type="button" :disabled="busy" @click="$emit('remove-domain')">移除</button>
</div>
<template v-if="phase === 'pending'">
<ol class="steps">
<li>在你的 DNS 服务商处添加这两条记录。</li>
<li>然后返回并点击 <strong>验证域名</strong>——DNS 变更可能需要几分钟生效。</li>
</ol>
<div class="records">
<div v-for="record in domain?.records || []" :key="`${record.type}-${record.host}`" class="record" :class="{ 'record--ok': record.verified }">
<div class="record__top">
<span class="record__type">{{ record.type }}</span>
<span class="record-pill" :class="record.verified ? 'record-pill--teal' : 'record-pill--warn'">
{{ record.verified ? '已检测到' : '等待中' }}
</span>
</div>
<div class="record__field">
<span class="record__label">名称 / 主机</span>
<div class="record__value">
<code>{{ record.host }}</code>
<button class="copy-btn" type="button" title="复制" @click="copy(record.host)">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>
</button>
</div>
</div>
<div class="record__field">
<span class="record__label">值</span>
<div class="record__value">
<code>{{ record.value }}</code>
<button class="copy-btn" type="button" title="复制" @click="copy(record.value)">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" /></svg>
</button>
</div>
</div>
</div>
</div>
<div class="verify-row">
<button class="btn-primary" type="button" :disabled="busy" @click="$emit('verify-domain')">
{{ busy ? '检查中…' : '验证域名' }}
</button>
<span class="ssl-note">
<span class="record-pill" :class="sslPill.cls">SSL {{ sslPill.label }}</span>
验证通过后自动签发
</span>
</div>
</template>
<template v-else>
<p class="domain-copy domain-copy--ok">
你的帮助中心已上线:<a :href="liveUrl" target="_blank" rel="noopener">{{ currentDomain }}</a>。
<span class="record-pill" :class="sslPill.cls">SSL {{ sslPill.label }}</span>
</p>
</template>
</template>
</div>
</template>
<style scoped>
.domain-head {
display: flex;
align-items: center;
gap: 10px;
}
.domain-head__title {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
color: var(--text);
margin: 0;
}
.domain-copy {
font-size: 13.5px;
color: var(--muted);
max-width: 560px;
line-height: 1.55;
margin: 6px 0 16px;
}
.domain-copy--ok {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.domain-copy a {
color: var(--c-teal);
font-weight: 600;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 12px;
border-radius: var(--radius-pill);
font-size: 12.5px;
font-weight: 600;
}
.status-pill__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.status-pill--teal {
background: var(--teal-bg);
border: 1px solid var(--teal-border);
color: var(--c-teal);
}
.status-pill--warn {
background: var(--warning-bg);
border: 1px solid color-mix(in srgb, var(--c-warn) 30%, transparent);
color: var(--c-warn);
}
.status-pill--idle {
background: var(--pill-idle-bg);
border: 1px solid var(--o12);
color: var(--pill-idle-fg);
}
.domain-form {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.domain-input {
flex: 1;
min-width: 240px;
display: flex;
align-items: center;
background: var(--bg);
border: 1px solid var(--o10);
border-radius: 11px;
padding: 0 14px;
font-family: var(--font-mono);
}
.domain-input__prefix {
color: var(--faint);
font-size: 13.5px;
}
.domain-input input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 13.5px;
padding: 13px 4px;
font-family: var(--font-mono);
}
.btn-primary {
padding: 0 20px;
min-height: 44px;
background: var(--accent-solid);
border: none;
border-radius: 11px;
color: var(--on-accent-solid);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.65;
cursor: default;
}
/* current domain header */
.domain-current {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
margin: 6px 0 16px;
background: var(--bg);
border: 1px solid var(--o08);
border-radius: 11px;
}
.domain-current__lead {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
color: var(--muted);
}
.domain-current__name {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.domain-current__name--link {
color: var(--c-teal);
text-decoration: none;
}
.link-btn {
flex-shrink: 0;
background: transparent;
border: none;
color: var(--muted);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.link-btn:hover {
color: var(--c-coral);
}
.link-btn:disabled {
opacity: 0.5;
cursor: default;
}
/* steps + records */
.steps {
margin: 0 0 14px;
padding-left: 20px;
font-size: 13.5px;
color: var(--muted);
line-height: 1.7;
}
.steps strong {
color: var(--text2);
}
.records {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 10px;
margin-bottom: 18px;
}
.record {
background: var(--bg);
border: 1px solid var(--o08);
border-radius: 12px;
padding: 13px 14px;
}
.record--ok {
border-color: var(--teal-border);
}
.record__top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.record__type {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--c-purple);
}
.record__field {
margin-top: 8px;
}
.record__label {
display: block;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--muted2);
margin-bottom: 4px;
}
.record__value {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 8px;
padding: 7px 9px;
}
.record__value code {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--text3);
overflow-x: auto;
white-space: nowrap;
}
.copy-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: var(--o05);
border: 1px solid var(--o12);
border-radius: 7px;
color: var(--muted);
cursor: pointer;
}
.copy-btn:hover {
background: var(--o08);
color: var(--text2);
}
.verify-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.ssl-note {
font-size: 12.5px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 8px;
}
.record-pill {
display: inline-block;
padding: 3px 9px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
}
.record-pill--teal {
background: var(--teal-bg);
border: 1px solid var(--teal-border);
color: var(--c-teal);
}
.record-pill--warn {
background: var(--warning-bg);
border: 1px solid color-mix(in srgb, var(--c-warn) 30%, transparent);
color: var(--c-warn);
}
.record-pill--coral {
background: var(--coral-bg);
border: 1px solid var(--coral-border);
color: var(--c-coral);
}
.record-pill--idle {
background: var(--o05);
border: 1px solid var(--o12);
color: var(--muted);
}
</style>
@@ -0,0 +1,362 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Cropper } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
// Generic image field (logo or favicon): file pick → square-optional crop →
// PNG export. Configurable size caps and crop aspect so one component serves both.
const props = withDefaults(
defineProps<{
imageUrl: string | null
kind?: 'logo' | 'favicon'
maxBytes?: number
outputMax?: number
square?: boolean
}>(),
{ kind: 'logo', maxBytes: 2 * 1024 * 1024, outputMax: 512, square: false },
)
const emit = defineEmits<{
upload: [file: File]
remove: []
}>()
// Raster only — SVG is rejected (it would be active markup on the public site).
const ACCEPT = 'image/png,image/jpeg,image/webp'
const maxMbLabel = computed(() => `${Math.round(props.maxBytes / (1024 * 1024))} MB`)
const stencilProps = computed(() => (props.square ? { aspectRatio: 1 } : {}))
const fileInput = ref<HTMLInputElement | null>(null)
const error = ref('')
const showCropper = ref(false)
const cropperImage = ref('')
const cropper = ref<any>(null)
const saving = ref(false)
const imageName = computed(() => {
if (!props.imageUrl) return ''
return props.imageUrl.split('/').pop()?.split('?')[0] || props.kind
})
function pick() {
error.value = ''
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (!/^image\/(png|jpe?g|webp)$/.test(file.type)) {
error.value = '请使用 PNG、JPG 或 WebP 图片。'
return
}
if (file.size > props.maxBytes) {
error.value = `图片不能超过 ${maxMbLabel.value}。`
return
}
error.value = ''
cropperImage.value = URL.createObjectURL(file)
showCropper.value = true
}
async function confirmCrop() {
if (!cropper.value || saving.value) return
saving.value = true
try {
const { canvas } = cropper.value.getResult()
if (!canvas) throw new Error('no canvas')
const blob: Blob = await new Promise((resolve, reject) => {
canvas.toBlob((b: Blob | null) => (b ? resolve(b) : reject(new Error('blob'))), 'image/png')
})
emit('upload', new File([blob], `${props.kind}.png`, { type: 'image/png' }))
closeCropper()
} catch {
error.value = '无法处理该图片,请换一张试试。'
} finally {
saving.value = false
}
}
function closeCropper() {
showCropper.value = false
if (cropperImage.value) URL.revokeObjectURL(cropperImage.value)
cropperImage.value = ''
}
</script>
<template>
<div>
<input ref="fileInput" class="file-input" type="file" :accept="ACCEPT" @change="onFileChange" />
<div v-if="imageUrl" class="logo-row">
<div class="logo-chip">
<img class="logo-chip__img" :src="imageUrl" :alt="kind" />
<div class="logo-chip__name">{{ imageName }}</div>
</div>
<button class="btn-sm" type="button" @click="pick">替换</button>
<button class="icon-btn" type="button" :title="`移除 ${kind === 'logo' ? 'Logo' : 'Favicon'}`" @click="$emit('remove')">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" /><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" /></svg>
</button>
</div>
<button v-else class="upload-btn" type="button" @click="pick">
<span class="upload-btn__icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4" /><path d="M8 8l4-4 4 4" /><path d="M4 20h16" /></svg>
</span>
<span>
<span class="upload-btn__title">上传 {{ kind === 'logo' ? 'Logo' : 'Favicon' }}</span>
<span class="upload-btn__sub">PNG、JPG 或 WebP,最大 {{ maxMbLabel }} · 可裁剪适配</span>
</span>
</button>
<p v-if="error" class="logo-error">{{ error }}</p>
<!-- Crop modal: the cropped canvas is exported as a fresh PNG, so nothing
from the original file (metadata, oversized pixels) reaches the server. -->
<div v-if="showCropper" class="crop-modal" @click.self="closeCropper">
<div class="crop-panel">
<div class="crop-head">裁剪 {{ kind === 'logo' ? 'Logo' : 'Favicon' }}</div>
<div class="crop-stage">
<Cropper
ref="cropper"
:src="cropperImage"
:canvas="{ maxWidth: outputMax, maxHeight: outputMax }"
:stencil-props="stencilProps"
image-restriction="fit-area"
background="var(--o05)"
/>
</div>
<div class="crop-actions">
<button class="btn-cancel" type="button" @click="closeCropper">取消</button>
<button class="btn-save" type="button" :disabled="saving" @click="confirmCrop">
{{ saving ? '保存中…' : `保存 ${kind === 'logo' ? 'Logo' : 'Favicon'}` }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.file-input {
display: none;
}
.logo-row {
display: flex;
align-items: center;
gap: 8px;
}
.logo-chip {
display: flex;
align-items: center;
gap: 9px;
padding: 10px 14px;
background: var(--bg);
border: 1px solid var(--o10);
border-radius: 11px;
margin-right: 6px;
min-width: 0;
}
.logo-chip__img {
width: 28px;
height: 28px;
border-radius: 8px;
object-fit: contain;
flex-shrink: 0;
}
.logo-chip__name {
font-size: 13px;
font-weight: 600;
color: var(--text2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.btn-sm {
padding: 9px 13px;
background: var(--o05);
border: 1px solid var(--o12);
border-radius: 9px;
color: var(--text2);
font-family: var(--font-sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-sm:hover {
background: var(--o08);
}
.icon-btn {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 9px;
background: var(--o05);
border: 1px solid var(--o12);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: var(--coral-bg);
color: var(--c-coral);
border-color: var(--coral-border);
}
.upload-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 16px;
background: var(--bg);
border: 1.5px dashed var(--o16);
border-radius: 12px;
cursor: pointer;
text-align: left;
font-family: var(--font-sans);
}
.upload-btn:hover {
border-color: var(--o30);
}
.upload-btn__icon {
width: 38px;
height: 38px;
flex-shrink: 0;
border-radius: 10px;
background: var(--o05);
border: 1px solid var(--o10);
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.upload-btn__title {
display: block;
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.upload-btn__sub {
display: block;
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.logo-error {
margin: 8px 0 0;
font-size: 12.5px;
color: var(--c-coral);
}
/* ---- crop modal ---- */
.crop-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(10, 12, 16, 0.55);
}
.crop-panel {
width: 100%;
max-width: 460px;
background: var(--surface, var(--bg));
border: 1px solid var(--o12);
border-radius: 16px;
box-shadow: 0 24px 60px rgba(10, 12, 16, 0.35);
overflow: hidden;
}
.crop-head {
padding: 16px 18px;
font-family: var(--font-sans);
font-size: 15px;
font-weight: 600;
color: var(--text2);
border-bottom: 1px solid var(--o10);
}
.crop-stage {
height: 320px;
background: var(--o05);
}
.crop-stage :deep(.vue-advanced-cropper) {
height: 100%;
}
.crop-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px;
border-top: 1px solid var(--o10);
}
.btn-cancel {
padding: 10px 16px;
background: transparent;
border: 1px solid var(--o12);
border-radius: 10px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.btn-save {
padding: 10px 18px;
background: var(--purple-bg);
border: 1px solid var(--purple-border);
border-radius: 10px;
color: var(--c-purple);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.55;
cursor: default;
}
</style>
@@ -0,0 +1,246 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { HelpCenterSettings } from '@/types/faq'
import { contrastInk } from '@/utils/color'
const props = defineProps<{ settings: HelpCenterSettings }>()
const brand = computed(() => props.settings.brand_color || '#4338CA')
const ink = computed(() => contrastInk(brand.value))
const address = computed(() => {
const domain = props.settings.domain
if (domain?.domain_status === 'verified' && domain.custom_domain) return domain.custom_domain
// live_url is mode-aware (path {origin}/help/{slug} or {slug}.{base}); show it
// without the scheme so the preview matches wherever the site is actually served.
if (props.settings.live_url) return props.settings.live_url.replace(/^https?:\/\//, '')
return props.settings.slug || 'your-company'
})
const rootStyle = computed(() => ({ '--hc-brand': brand.value, '--hc-ink': ink.value }))
// Hero tint from the brand color at low alpha (1F = ~12%).
const heroStyle = computed(() => ({
background: `linear-gradient(180deg, ${brand.value}1F, #FFFFFF 92%)`,
}))
</script>
<template>
<div class="preview" :style="rootStyle">
<!-- browser chrome -->
<div class="preview__chrome">
<span class="preview__dot"></span><span class="preview__dot"></span><span class="preview__dot"></span>
<div class="preview__address">{{ address }}</div>
</div>
<!-- site header -->
<div class="preview__header">
<div class="preview__brand">
<img v-if="settings.logo_url" class="preview__logo-img" :src="settings.logo_url" alt="Logo" />
<div v-else class="preview__logo-mark"><div class="preview__logo-diamond"></div></div>
<span class="preview__brand-name">{{ settings.title || '你的公司' }}</span>
</div>
<div class="preview__nav">
<span v-for="link in settings.header_links" :key="link.label" class="preview__nav-link">{{ link.label }}</span>
<span v-if="settings.cta_enabled && settings.cta_text" class="preview__cta">{{ settings.cta_text }}</span>
</div>
</div>
<!-- hero -->
<div class="preview__hero" :style="heroStyle">
<div class="preview__hero-title">有什么可以帮你?</div>
<div class="preview__search">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#9AA0AD" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
<span class="preview__search-hint">搜索答案…</span>
<span class="preview__search-btn">搜索</span>
</div>
</div>
</div>
</template>
<style scoped>
/* This component simulates the visitor-facing help center, an external
light-mode website — the fixed light palette below is deliberate and must
NOT follow the admin app's theme tokens. */
.preview {
border-radius: 14px;
overflow: hidden;
border: 1px solid var(--o10);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
}
.preview__chrome {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 13px;
background: #ebedf1;
border-bottom: 1px solid #dde0e7;
}
.preview__dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #c6cad3;
}
.preview__address {
margin-left: 9px;
flex: 1;
height: 20px;
border-radius: 6px;
background: #ffffff;
display: flex;
align-items: center;
padding: 0 11px;
font-family: var(--font-mono);
font-size: 10.5px;
color: #9aa0ad;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
background: #ffffff;
border-bottom: 1px solid #ecedf2;
}
.preview__brand {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
.preview__logo-img {
width: 28px;
height: 28px;
border-radius: 8px;
object-fit: contain;
flex-shrink: 0;
}
.preview__logo-mark {
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--hc-brand);
}
.preview__logo-diamond {
width: 11px;
height: 11px;
border-radius: 3px;
background: var(--hc-ink);
transform: rotate(45deg);
}
.preview__brand-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 14px;
color: #1a1b25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview__nav {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.preview__nav-link {
font-size: 12.5px;
color: #5a6172;
white-space: nowrap;
}
.preview__cta {
display: inline-flex;
align-items: center;
padding: 8px 15px;
border-radius: 9px;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
background: var(--hc-brand);
color: var(--hc-ink);
}
.preview__hero {
padding: 22px 20px 20px;
}
.preview__hero-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 19px;
color: #1a1b25;
text-align: center;
margin-bottom: 13px;
}
.preview__search {
display: flex;
align-items: center;
gap: 8px;
background: #ffffff;
border: 1px solid #e4e6ec;
border-radius: 11px;
padding: 5px 5px 5px 13px;
max-width: 340px;
margin: 0 auto;
box-shadow: 0 8px 20px rgba(40, 36, 90, 0.08);
}
.preview__search svg {
flex-shrink: 0;
}
.preview__search-hint {
flex: 1;
font-size: 12.5px;
color: #9aa0ad;
}
.preview__search-btn {
display: inline-flex;
align-items: center;
padding: 9px 16px;
border-radius: 9px;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
background: var(--hc-brand);
color: var(--hc-ink);
}
</style>
@@ -0,0 +1,316 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import type { HelpCenterSettings } from '@/types/faq'
import HelpCenterDomainPanel from './HelpCenterDomainPanel.vue'
defineProps<{
settings: HelpCenterSettings
domainBusy: boolean
}>()
const emit = defineEmits<{
'toggle-enabled': [enabled: boolean]
'update-ai': [payload: { agent_id?: string | null; ai_search_enabled?: boolean; chat_widget_enabled?: boolean }]
'set-domain': [domain: string]
'verify-domain': []
'remove-domain': []
}>()
function onAgentChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
emit('update-ai', { agent_id: value || null })
}
</script>
<template>
<section class="public">
<h2 class="public__heading">公开帮助中心</h2>
<div class="public__card">
<!-- publish row -->
<div class="publish-row">
<div class="publish-row__lead">
<label class="switch">
<input type="checkbox" :checked="settings.enabled" @change="emit('toggle-enabled', ($event.target as HTMLInputElement).checked)" />
<span class="switch__track"><span class="switch__knob"></span></span>
</label>
<span class="status-pill" :class="settings.enabled ? 'status-pill--teal' : 'status-pill--idle'">
<span class="status-pill__dot" :class="{ 'status-pill__dot--glow': settings.enabled }"></span>
{{ settings.enabled ? '已发布' : '草稿' }}
</span>
<span class="publish-row__text">已发布 {{ settings.published_count }} 条常见问题,地址:</span>
<a v-if="settings.live_url" class="publish-row__url" :href="settings.live_url" target="_blank" rel="noopener">{{ settings.live_url }}</a>
</div>
<a v-if="settings.live_url" class="btn-ghost" :href="settings.live_url" target="_blank" rel="noopener">打开帮助中心 →</a>
</div>
<!-- AI agent + surfaces -->
<div class="block">
<label class="mono-label">AI 智能体</label>
<div class="ai-row">
<select class="agent-select" :value="settings.agent_id || ''" @change="onAgentChange">
<option value="">选择智能体…</option>
<option v-for="agent in settings.agents" :key="agent.id" :value="agent.id">{{ agent.name }}</option>
</select>
</div>
<div class="toggle-list">
<label class="switch switch--labelled">
<input type="checkbox" :checked="settings.ai_search_enabled" @change="emit('update-ai', { ai_search_enabled: ($event.target as HTMLInputElement).checked })" />
<span class="switch__track"><span class="switch__knob"></span></span>
<span class="switch__label">
搜索中的 AI 快速摘要
<span class="switch__sub">在搜索结果上方即时给出 AI 回答,基于该智能体的知识。</span>
</span>
</label>
<label class="switch switch--labelled">
<input type="checkbox" :checked="settings.chat_widget_enabled" @change="emit('update-ai', { chat_widget_enabled: ($event.target as HTMLInputElement).checked })" />
<span class="switch__track"><span class="switch__knob"></span></span>
<span class="switch__label">
聊天挂件
<span class="switch__sub">嵌入该智能体的聊天挂件,让访客可以直接发起对话。</span>
</span>
</label>
</div>
<p class="hint">两种展示都使用上方选择的智能体——可以只选一个、两个都选,或都不选。</p>
</div>
<!-- custom domain -->
<div class="block">
<HelpCenterDomainPanel
:domain="settings.domain"
:busy="domainBusy"
@set-domain="emit('set-domain', $event)"
@verify-domain="emit('verify-domain')"
@remove-domain="emit('remove-domain')"
/>
</div>
</div>
</section>
</template>
<style scoped>
.public__heading {
font-family: var(--font-display);
font-weight: 600;
font-size: 19px;
letter-spacing: -0.01em;
color: var(--text);
margin: 0 0 16px;
}
.public__card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 18px;
overflow: hidden;
}
.publish-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
border-bottom: 1px solid var(--o07);
flex-wrap: wrap;
}
.publish-row__lead {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.publish-row__text {
font-size: 14px;
color: var(--muted);
}
.publish-row__url {
font-family: var(--font-mono);
font-size: 13px;
color: var(--accent-ink);
text-decoration: none;
}
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--o05);
border: 1px solid var(--o14);
border-radius: 10px;
color: var(--text2);
text-decoration: none;
font-size: 13.5px;
font-weight: 600;
}
.btn-ghost:hover {
background: var(--o08);
}
.switch {
display: inline-flex;
align-items: center;
gap: 9px;
cursor: pointer;
}
.switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch__track {
flex-shrink: 0;
width: 38px;
height: 22px;
border-radius: var(--radius-pill);
background: var(--toggle-track-off);
padding: 2px;
display: inline-flex;
transition: background-color var(--transition-fast);
box-sizing: border-box;
}
.switch input:checked + .switch__track {
background: var(--toggle-on-teal);
}
.switch__knob {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--toggle-knob);
transition: transform var(--transition-fast);
}
.switch input:checked + .switch__track .switch__knob {
transform: translateX(16px);
}
.switch__label {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
font-size: 13.5px;
font-weight: 500;
color: var(--text2);
}
.switch__sub {
font-size: 12px;
font-weight: 400;
color: var(--text3, var(--text2));
opacity: 0.85;
}
.toggle-list {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 14px;
}
.switch--labelled {
align-items: flex-start;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 12px;
border-radius: var(--radius-pill);
font-size: 12.5px;
font-weight: 600;
}
.status-pill__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.status-pill__dot--glow {
box-shadow: 0 0 6px var(--c-teal);
}
.status-pill--teal {
background: var(--teal-bg);
border: 1px solid var(--teal-border);
color: var(--c-teal);
}
.status-pill--idle {
background: var(--pill-idle-bg);
border: 1px solid var(--o12);
color: var(--pill-idle-fg);
}
.block {
padding: 22px;
border-bottom: 1px solid var(--o07);
}
.block:last-child {
border-bottom: none;
}
.mono-label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--muted2);
margin-bottom: 12px;
}
.ai-row {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
}
.agent-select {
min-width: 220px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 10px;
padding: 10px 12px;
font-family: var(--font-sans);
font-size: 13.5px;
color: var(--text);
outline: none;
}
.hint {
font-size: 12.5px;
color: var(--muted);
margin: 12px 0 0;
line-height: 1.55;
}
</style>
@@ -0,0 +1,266 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { marked } from 'marked'
import { toast } from 'vue-sonner'
import { faqService } from '@/services/faq'
const props = withDefaults(
defineProps<{ modelValue: string; placeholder?: string }>(),
{ placeholder: '回答——支持 Markdown(标题、**加粗**、列表、链接、图片)' },
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const textarea = ref<HTMLTextAreaElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const mode = ref<'write' | 'preview'>('write')
const uploading = ref(false)
// Preview renders in a sandboxed iframe (no allow-scripts): it safely shows
// images/links from the author's Markdown without executing anything, and
// sidesteps the app-wide DOMPurify hook that strips <img> elsewhere.
const previewDoc = computed(() => {
const body = props.modelValue.trim()
? (marked.parse(props.modelValue, { breaks: true, async: false }) as string)
: '<p style="color:#9aa0ad">暂无可预览的内容。</p>'
return `<!doctype html><html><head><meta charset="utf-8"><style>
body{font-family:system-ui,-apple-system,sans-serif;font-size:14px;line-height:1.7;color:#2a303e;margin:14px;}
h1,h2,h3,h4{font-weight:700;line-height:1.3;margin:20px 0 10px;color:#1a1b25;}
h1{font-size:20px}h2{font-size:17px}h3{font-size:15px}
a{color:#4338ca;font-weight:600}
img{max-width:100%;height:auto;border-radius:10px;border:1px solid #ececf2;margin:6px 0}
ul,ol{padding-left:22px}li{margin:4px 0}
blockquote{margin:6px 0;padding:10px 14px;background:#f4f2fd;border-left:3px solid #6d5bd0;border-radius:8px}
code{font-family:ui-monospace,monospace;background:#f1f2f6;border-radius:4px;padding:1px 5px}
pre{background:#f1f2f6;border-radius:10px;padding:12px;overflow-x:auto}
pre code{background:none;padding:0}
table{border-collapse:collapse;width:100%}th,td{border:1px solid #ececf2;padding:6px 9px}
</style></head><body>${body}</body></html>`
})
/** Replace the current selection (or insert at caret), then restore focus/caret. */
function replaceSelection(transform: (selected: string) => { text: string; caretOffset?: number }) {
const el = textarea.value
if (!el) return
const start = el.selectionStart
const end = el.selectionEnd
const value = props.modelValue
const selected = value.slice(start, end)
const { text, caretOffset } = transform(selected)
const next = value.slice(0, start) + text + value.slice(end)
emit('update:modelValue', next)
const caret = caretOffset === undefined ? start + text.length : start + caretOffset
nextTick(() => {
el.focus()
el.setSelectionRange(caret, caret)
})
}
function wrap(before: string, after: string, placeholder: string) {
replaceSelection((sel) => {
const inner = sel || placeholder
return { text: `${before}${inner}${after}`, caretOffset: before.length + inner.length }
})
}
function prefixLine(prefix: string, placeholder: string) {
replaceSelection((sel) => {
const inner = sel || placeholder
return { text: `${prefix}${inner}`, caretOffset: prefix.length + inner.length }
})
}
function insertLink() {
replaceSelection((sel) => {
const label = sel || '链接文字'
return { text: `[${label}](https://)`, caretOffset: label.length + 3 }
})
}
async function onFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploading.value = true
try {
const url = await faqService.uploadImage(file)
const alt = file.name.replace(/\.[^.]+$/, '')
replaceSelection(() => ({ text: `\n![${alt}](${url})\n` }))
} catch (error: any) {
toast.error(error?.message || '图片上传失败')
} finally {
uploading.value = false
}
}
</script>
<template>
<div class="md-editor">
<div class="md-toolbar">
<div class="md-tools">
<button type="button" class="md-btn" title="标题" @click="prefixLine('## ', '标题')">H</button>
<button type="button" class="md-btn md-btn--bold" title="加粗" @click="wrap('**', '**', '加粗')">B</button>
<button type="button" class="md-btn" title="无序列表" @click="prefixLine('- ', '列表项')">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="3.5" cy="6" r="1"/><circle cx="3.5" cy="12" r="1"/><circle cx="3.5" cy="18" r="1"/></svg>
</button>
<button type="button" class="md-btn" title="链接" @click="insertLink">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></svg>
</button>
<button type="button" class="md-btn" title="插入图片" :disabled="uploading" @click="fileInput?.click()">
<svg v-if="!uploading" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
<span v-else class="md-spin"></span>
</button>
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif,image/webp" hidden @change="onFile" />
</div>
<div class="md-modes">
<button type="button" class="md-mode" :class="{ 'md-mode--on': mode === 'write' }" @click="mode = 'write'">编写</button>
<button type="button" class="md-mode" :class="{ 'md-mode--on': mode === 'preview' }" @click="mode = 'preview'">预览</button>
</div>
</div>
<textarea
v-show="mode === 'write'"
ref="textarea"
class="md-textarea"
rows="8"
:placeholder="placeholder"
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
></textarea>
<iframe v-show="mode === 'preview'" class="md-preview" :srcdoc="previewDoc" sandbox="" title="回答预览"></iframe>
</div>
</template>
<style scoped>
.md-editor {
border: 1px solid var(--o12);
border-radius: 10px;
overflow: hidden;
background: var(--bg);
}
.md-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-bottom: 1px solid var(--o08);
background: var(--o03);
}
.md-tools {
display: flex;
align-items: center;
gap: 4px;
}
.md-btn {
min-width: 30px;
height: 30px;
padding: 0 7px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 13.5px;
cursor: pointer;
}
.md-btn:hover {
background: var(--o08);
color: var(--text2);
}
.md-btn:disabled {
opacity: 0.6;
cursor: default;
}
.md-btn--bold {
font-weight: 700;
}
.md-modes {
display: flex;
gap: 2px;
background: var(--o05);
border-radius: 8px;
padding: 2px;
}
.md-mode {
padding: 5px 11px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.md-mode--on {
background: var(--surface);
color: var(--text);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.md-textarea {
width: 100%;
display: block;
background: var(--bg);
border: none;
padding: 12px 13px;
color: var(--text);
font-family: var(--font-sans);
font-size: 13.5px;
line-height: 1.6;
outline: none;
resize: vertical;
box-sizing: border-box;
}
.md-preview {
width: 100%;
height: 240px;
border: none;
background: #fff;
display: block;
}
.md-spin {
width: 13px;
height: 13px;
border: 2px solid var(--o16);
border-top-color: var(--c-purple);
border-radius: 50%;
animation: md-spin 0.7s linear infinite;
}
@keyframes md-spin {
to { transform: rotate(360deg); }
}
</style>
@@ -0,0 +1,342 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import channelsService, { type ChannelAccount, type SmsProviderInfo } from '@/services/channels'
import { agentService } from '@/services/agent'
import type { Agent } from '@/types/agent'
import CopyField from './CopyField.vue'
const props = defineProps<{
channel: 'email' | 'sms' | 'line' | 'slack'
// When set, the modal opens in "manage" mode for an already-connected
// account: it skips credential entry and shows the webhook URL + agent.
// Slack always uses this (it connects via OAuth, so only the agent step).
existingAccount?: ChannelAccount | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'connected', account: ChannelAccount): void
}>()
const FORMS = {
email: {
title: '连接 Email',
intro: '填写客户来信的客服邮箱地址,然后将服务商的入站解析 / 转发 Webhook 指向下方显示的 URL。可选:配置自己的发信 SMTP,以便使用自有域名发送回复并正确设置 SPF/DKIM(留空则使用平台邮件服务器)。',
fields: [
{ key: 'inbound_address', label: '客服邮箱地址', placeholder: 'support@yourcompany.com', secret: false },
{ key: 'smtp_host', label: 'SMTP 主机(可选)', placeholder: 'smtp.yourprovider.com', secret: false, optional: true },
{ key: 'smtp_port', label: 'SMTP 端口(可选)', placeholder: '587', secret: false, optional: true },
{ key: 'smtp_username', label: 'SMTP 用户名(可选)', placeholder: 'apikey / user', secret: false, optional: true },
{ key: 'smtp_password', label: 'SMTP 密码(可选)', placeholder: '••••••••', secret: true, optional: true },
{ key: 'from_email', label: '发件地址(可选)', placeholder: '默认使用客服邮箱地址', secret: false, optional: true },
],
connect: (v: Record<string, string>) => {
const payload: any = { inbound_address: v.inbound_address }
if (v.smtp_host?.trim()) {
payload.smtp_host = v.smtp_host.trim()
if (v.smtp_port?.trim()) payload.smtp_port = Number(v.smtp_port)
payload.smtp_username = v.smtp_username
payload.smtp_password = v.smtp_password
if (v.from_email?.trim()) payload.from_email = v.from_email.trim()
}
return channelsService.connectEmail(payload)
},
},
sms: {
title: '连接 SMS',
intro: '选择短信服务商,填写发信号码及其 API 凭据。连接完成后,将显示的 URL 设置为该号码的入站消息 Webhook(SNS 使用 HTTPS 主题订阅)。',
// Fields are provider-specific and resolved dynamically (see activeFields)
fields: [],
connect: (v: Record<string, string>) => {
const info = smsProviders.value.find(p => p.name === selectedProvider.value)
const credentials: Record<string, string> = {}
for (const f of info?.fields || []) {
if (v[f.key]?.trim()) credentials[f.key] = v[f.key].trim()
}
return channelsService.connectSms({
provider: selectedProvider.value,
phone_number: v.phone_number,
credentials,
})
},
},
line: {
title: '连接 LINE',
intro: '在 LINE Developers 控制台(Messaging API 渠道)中复制 Channel secret 并签发 Channel access token。Webhook 会自动配置。',
fields: [
{ key: 'channel_secret', label: 'Channel secret', placeholder: '••••••••', secret: true },
{ key: 'channel_access_token', label: 'Channel access token', placeholder: '长期有效 Token', secret: true },
],
connect: (v: Record<string, string>) => channelsService.connectLine({
channel_secret: v.channel_secret, channel_access_token: v.channel_access_token }),
},
slack: {
// Slack connects via OAuth (no credential form); this modal is only used
// in manage mode to pick the answering agent.
title: 'Slack',
intro: '选择由哪个 AI 智能体应答 Slack 中的提及和私信。',
fields: [],
connect: async () => { throw new Error('Slack connects via OAuth') },
},
} as const
const form = computed(() => FORMS[props.channel])
const values = ref<Record<string, string>>({})
const connecting = ref(false)
// In manage mode we start at the second step with the existing account
const account = ref<ChannelAccount | null>(props.existingAccount ?? null)
const isManage = computed(() => !!props.existingAccount)
// SMS providers (dynamic credential fields per provider)
const smsProviders = ref<SmsProviderInfo[]>([])
const selectedProvider = ref('twilio')
// The credential fields to render: dynamic for SMS, static otherwise
const activeFields = computed(() => {
if (props.channel !== 'sms') return form.value.fields
const info = smsProviders.value.find(p => p.name === selectedProvider.value)
return [
{ key: 'phone_number', label: '电话号码 / 发送方 ID', placeholder: '+15551234567', secret: false },
...(info?.fields || []).map(f => ({
key: f.key, label: f.label, placeholder: f.secret ? '••••••••' : '',
secret: f.secret, optional: f.optional,
})),
]
})
const agents = ref<Agent[]>([])
const selectedAgentId = ref('')
const savingAgent = ref(false)
onMounted(async () => {
try {
if (props.channel === 'sms') {
smsProviders.value = await channelsService.listSmsProviders()
}
agents.value = await agentService.getOrganizationAgents()
// Default the agent selector to the account's current agent, else the first
selectedAgentId.value = String(
props.existingAccount?.agent_id || agents.value[0]?.id || '')
} catch (error) {
console.error('Error loading modal data:', error)
}
})
const connect = async () => {
const missing = activeFields.value.filter(f => !(f as any).optional && !values.value[f.key]?.trim())
if (missing.length > 0) {
toast.error(`请填写:${missing.map(f => f.label).join('、')}`)
return
}
try {
connecting.value = true
const trimmed = Object.fromEntries(
Object.entries(values.value).map(([k, v]) => [k, v.trim()]))
account.value = await form.value.connect(trimmed)
toast.success(`已连接 ${account.value.display_name || form.value.title.replace('连接 ', '')}`)
} catch (error: any) {
toast.error(error?.response?.data?.detail || `连接 ${props.channel} 失败`)
} finally {
connecting.value = false
}
}
const saveAgent = async () => {
if (!account.value || !selectedAgentId.value) return
try {
savingAgent.value = true
const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value)
toast.success('智能体已分配 — 该渠道已生效!')
emit('connected', updated)
} catch (error: any) {
toast.error(error?.response?.data?.detail || '分配智能体失败')
} finally {
savingAgent.value = false
}
}
</script>
<template>
<!-- Intentionally no backdrop-click dismiss: these forms hold credentials
the user is mid-entry on, so only the × / Cancel buttons close it. -->
<div class="cc-modal">
<div class="cc-modal-content">
<div class="cc-modal-header">
<h3>{{ isManage ? form.title.replace('连接', '管理') : form.title }}</h3>
<button class="cc-close-btn" @click="emit('close')">×</button>
</div>
<!-- Step 1: credentials -->
<div v-if="!account" class="cc-modal-body">
<p class="cc-intro">{{ form.intro }}</p>
<!-- SMS provider picker -->
<div v-if="channel === 'sms'" class="cc-field">
<label class="cc-label" for="cc-provider">短信服务商</label>
<select id="cc-provider" v-model="selectedProvider" class="cc-input">
<option v-for="p in smsProviders" :key="p.name" :value="p.name">{{ p.label }}</option>
</select>
</div>
<div v-for="field in activeFields" :key="field.key" class="cc-field">
<label class="cc-label" :for="`cc-${field.key}`">{{ field.label }}</label>
<input
:id="`cc-${field.key}`"
v-model="values[field.key]"
:type="field.secret ? 'password' : 'text'"
class="cc-input"
:placeholder="(field as any).placeholder"
:name="`cc-${channel}-${field.key}`"
:autocomplete="field.secret ? 'new-password' : 'off'"
/>
</div>
<div class="cc-actions">
<button class="cc-btn cc-btn-secondary" @click="emit('close')">取消</button>
<button class="cc-btn cc-btn-primary" :disabled="connecting" @click="connect">
{{ connecting ? '连接中…' : '连接' }}
</button>
</div>
</div>
<!-- Step 2: webhook URL (if applicable) + agent routing -->
<div v-else class="cc-modal-body">
<p class="cc-intro">
<strong>{{ account.display_name }}</strong> 已连接。
</p>
<CopyField v-if="account.webhook_url" class="cc-field" field-id="cc-webhook-url"
label="Webhook URL — 在你的服务商处设置" name="Webhook URL"
:value="account.webhook_url" />
<label class="cc-label" for="cc-agent">负责应答该渠道的 AI 智能体</label>
<select id="cc-agent" v-model="selectedAgentId" class="cc-input">
<option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)">
{{ agent.display_name || agent.name }}
</option>
</select>
<div class="cc-actions">
<button v-if="isManage && channel !== 'slack'" class="cc-btn cc-btn-secondary" @click="account = null">
重新配置凭据
</button>
<button v-else class="cc-btn cc-btn-secondary" @click="emit('connected', account)">暂时跳过</button>
<button class="cc-btn cc-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent">
{{ savingAgent ? '保存中…' : '分配智能体' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.cc-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.cc-modal-content {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 12px);
width: min(480px, calc(100vw - 32px));
padding: 24px;
}
.cc-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.cc-modal-header h3 {
margin: 0;
font-family: var(--font-display);
}
.cc-close-btn {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: var(--muted);
}
.cc-intro {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.cc-field {
margin-bottom: 12px;
}
.cc-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.cc-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
color: inherit;
font-size: 14px;
}
.cc-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.cc-btn {
padding: 9px 16px;
border-radius: var(--radius-btn, 8px);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--background-soft);
color: inherit;
white-space: nowrap;
}
.cc-btn-primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
border-color: transparent;
}
.cc-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,99 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!-- A read-only value the customer copies into another product's settings
(a webhook URL, a verify token). -->
<script setup lang="ts">
import { toast } from 'vue-sonner'
const props = defineProps<{
fieldId: string
label: string
/** What the toast calls it ("Webhook URL copied") */
name: string
value: string
}>()
const copy = async () => {
try {
await navigator.clipboard.writeText(props.value)
toast.success(`${props.name}已复制`)
} catch {
// No clipboard on plain-http origins: the field stays selectable
toast.error('复制失败,请选中文本后手动复制。')
}
}
</script>
<template>
<div class="copy-field">
<label class="copy-label" :for="fieldId">{{ label }}</label>
<div class="copy-row">
<input :id="fieldId" class="copy-input" :value="value" readonly />
<button class="copy-btn" type="button" @click="copy">复制</button>
</div>
</div>
</template>
<style scoped>
.copy-field + .copy-field {
margin-top: 10px;
}
.copy-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.copy-row {
display: flex;
gap: 8px;
}
.copy-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
background: var(--background-soft);
color: inherit;
font-size: 13px;
}
.copy-btn {
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
background: var(--background-soft);
color: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.copy-btn:hover {
border-color: var(--muted);
}
.copy-btn:focus-visible,
.copy-input:focus-visible {
outline: 2px solid var(--accent-ink);
outline-offset: 1px;
}
</style>
@@ -0,0 +1,126 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
Facebook Login for Business can grant several Pages at once, so the customer
chooses which one this channel answers. WhatsApp Embedded Signup never needs
this — it creates the single number itself.
-->
<script setup lang="ts">
import { ref } from 'vue'
import type { MessengerSignupPage } from '@/services/channels'
defineProps<{
pages: MessengerSignupPage[]
connecting: boolean
}>()
const emit = defineEmits<{
(e: 'select', pageId: string): void
}>()
// Which row is being connected, so only it shows a spinner (and the rest lock).
const pendingId = ref('')
const choose = (pageId: string) => {
pendingId.value = pageId
emit('select', pageId)
}
</script>
<template>
<div class="picker">
<p class="picker-intro">选择要连接的账号:</p>
<ul class="picker-list">
<li v-for="page in pages" :key="page.id">
<button
type="button"
class="picker-row"
:disabled="connecting"
@click="choose(page.id)"
>
<span class="picker-name">{{ page.name }}</span>
<font-awesome-icon
v-if="connecting && pendingId === page.id"
icon="fa-solid fa-spinner"
spin
/>
<font-awesome-icon v-else icon="fa-solid fa-chevron-right" class="picker-chevron" />
</button>
</li>
</ul>
</div>
</template>
<style scoped>
.picker {
display: flex;
flex-direction: column;
gap: 12px;
}
.picker-intro {
margin: 0;
color: var(--muted);
font-size: 14px;
}
.picker-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.picker-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
color: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.picker-row:hover:not(:disabled) {
border-color: var(--accent-solid);
}
.picker-row:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.picker-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-chevron {
color: var(--muted);
flex-shrink: 0;
}
</style>
@@ -0,0 +1,402 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import channelsService, { type ChannelAccount } from '@/services/channels'
import { agentService } from '@/services/agent'
import type { Agent } from '@/types/agent'
import MessengerPagePicker from './MessengerPagePicker.vue'
import MetaOwnAppWebhook from './MetaOwnAppWebhook.vue'
import { useMetaSignup } from '@/composables/useMetaSignup'
const props = defineProps<{
channel: 'whatsapp' | 'messenger' | 'instagram'
existingAccount?: ChannelAccount | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'connected', account: ChannelAccount): void
}>()
// Where the credentials are copied from — linked so it's one click, not a
// hostname to retype.
const META_APPS_URL = 'https://developers.facebook.com/apps/'
// Meta signs your own app's webhooks with its secret: without it a number or
// Page from your app can send but never receives.
const APP_SECRET_FIELD = {
key: 'app_secret', label: 'App Secret(可选)', placeholder: 'App settings → Basic', secret: true,
} as const
// Per-channel copy + credential fields. The intro is split around the link
// rather than carrying markup, so it renders as escaped text.
const META_FORMS = {
whatsapp: {
title: '连接 WhatsApp',
introBefore: '在你的 Meta 应用中(',
introAfter: ' → WhatsApp → API Setup)复制电话号码 ID 和永久访问 Token。添加 App Secret 后才能接收消息。',
fields: [
{ key: 'phone_number_id', label: '电话号码 ID', placeholder: '1234567890', secret: false },
{ key: 'access_token', label: '访问 Token', placeholder: 'EAAG…', secret: true },
{ key: 'waba_id', label: 'WhatsApp Business 账号 ID(可选)', placeholder: '用于自动订阅 Webhook', secret: false },
APP_SECRET_FIELD,
],
},
messenger: {
title: '连接 Messenger',
introBefore: '在你的 Meta 应用中(',
introAfter: ' → Messenger → 设置)为要连接的 Facebook 公共主页生成公共主页访问 Token。添加 App Secret 后才能接收消息。',
fields: [
{ key: 'page_id', label: 'Facebook 公共主页 ID', placeholder: '1234567890', secret: false },
{ key: 'page_access_token', label: '公共主页访问 Token', placeholder: 'EAAG…', secret: true },
APP_SECRET_FIELD,
],
},
instagram: {
title: '连接 Instagram',
introBefore: '你的 Instagram 账号必须是已关联 Facebook 公共主页的专业账号。请使用所关联公共主页的访问 Token,来源:',
introAfter: ' → Instagram。',
fields: [
{ key: 'ig_id', label: 'Instagram 账号 ID', placeholder: '17841400000000000', secret: false },
{ key: 'page_access_token', label: '关联公共主页访问 Token', placeholder: 'EAAG…', secret: true },
],
},
} as const
const form = computed(() => META_FORMS[props.channel])
const values = ref<Record<string, string>>({})
const connecting = ref(false)
const account = ref<ChannelAccount | null>(props.existingAccount ?? null)
const agents = ref<Agent[]>([])
const selectedAgentId = ref('')
const savingAgent = ref(false)
// The three one-click logins live in their own composable; this component owns
// the manual credentials form and agent assignment.
const {
signupEnabled,
signingUp,
showManualForm,
signupPages,
connectingPage,
copy: signupCopy,
startSignup,
onPageSelected,
} = useMetaSignup({
channel: props.channel,
existingAccount: props.existingAccount,
onConnected: (connected) => { account.value = connected },
})
onMounted(async () => {
try {
agents.value = await agentService.getOrganizationAgents()
selectedAgentId.value = String(
props.existingAccount?.agent_id || agents.value[0]?.id || '')
} catch (error) {
console.error('Error loading agents:', error)
}
})
const connect = async () => {
const missing = form.value.fields.filter(f => !f.label.includes('可选') && !values.value[f.key]?.trim())
if (missing.length > 0) {
toast.error(`请填写:${missing.map(f => f.label).join('、')}`)
return
}
try {
connecting.value = true
const payload: any = Object.fromEntries(
form.value.fields
.map(f => [f.key, values.value[f.key]?.trim()])
.filter(([, v]) => v)
)
if (props.channel === 'whatsapp') {
account.value = await channelsService.connectWhatsApp(payload)
} else if (props.channel === 'messenger') {
account.value = await channelsService.connectMessenger(payload)
} else {
account.value = await channelsService.connectInstagram(payload)
}
toast.success(`已连接 ${account.value.display_name || form.value.title.replace('连接 ', '')}`)
} catch (error: any) {
toast.error(error?.response?.data?.detail || `连接 ${props.channel} 失败`)
} finally {
connecting.value = false
}
}
const saveAgent = async () => {
if (!account.value || !selectedAgentId.value) return
try {
savingAgent.value = true
const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value)
toast.success('智能体已分配 — 该渠道已生效!')
emit('connected', updated)
} catch (error: any) {
toast.error(error?.response?.data?.detail || '分配智能体失败')
} finally {
savingAgent.value = false
}
}
</script>
<template>
<div class="meta-modal" @click.self="emit('close')">
<div class="meta-modal-content">
<div class="meta-modal-header">
<h3>{{ form.title }}</h3>
<button class="meta-close-btn" @click="emit('close')">×</button>
</div>
<!-- Step 1: credentials -->
<div v-if="!account" class="meta-modal-body">
<!-- Login for Business can grant several accounts at once; the customer
picks which one this channel answers. -->
<MessengerPagePicker
v-if="signupPages.length"
:pages="signupPages"
:connecting="connectingPage"
@select="onPageSelected"
/>
<!-- One-click signup under ChatterMate's Meta app; the manual form
stays available for anyone who already has their own credentials. -->
<div v-else-if="signupEnabled && !showManualForm" class="meta-signup">
<p class="meta-intro">{{ signupCopy.intro }}</p>
<button class="meta-btn meta-btn-primary meta-signup-btn" :disabled="signingUp" @click="startSignup">
<font-awesome-icon v-if="signingUp" icon="fa-solid fa-spinner" spin />
{{ signingUp ? '正在等待 Meta…' : signupCopy.cta }}
</button>
<button class="meta-link-btn" @click="showManualForm = true">
改为手动输入凭据
</button>
</div>
<template v-else>
<p class="meta-intro">
{{ form.introBefore }}<a
:href="META_APPS_URL"
target="_blank"
rel="noopener noreferrer"
class="meta-intro-link"
>developers.facebook.com</a>{{ form.introAfter }}
</p>
<div v-for="field in form.fields" :key="field.key" class="meta-field">
<label class="meta-label" :for="`meta-${field.key}`">{{ field.label }}</label>
<input
:id="`meta-${field.key}`"
v-model="values[field.key]"
:type="field.secret ? 'password' : 'text'"
class="meta-input"
:placeholder="field.placeholder"
:name="`meta-${channel}-${field.key}`"
:autocomplete="field.secret ? 'new-password' : 'off'"
/>
</div>
<div class="meta-actions">
<button class="meta-btn meta-btn-secondary" @click="emit('close')">取消</button>
<button class="meta-btn meta-btn-primary" :disabled="connecting" @click="connect">
{{ connecting ? '连接中…' : '连接' }}
</button>
</div>
</template>
</div>
<!-- Step 2: route to an agent -->
<div v-else class="meta-modal-body">
<div v-if="account.needs_reconnect" class="meta-reconnect">
<p class="meta-reconnect-text">Meta 已停止接受该账号的访问权限,请重新连接以恢复回复。</p>
<button class="meta-btn meta-btn-primary" @click="account = null">重新连接</button>
</div>
<p class="meta-intro">
<strong>{{ account.display_name }}</strong> 已连接。
</p>
<MetaOwnAppWebhook v-if="account.webhook_url && account.webhook_verify_token" :account="account" />
<p class="meta-intro">选择由哪个 AI 智能体应答其消息:</p>
<label class="meta-label" for="meta-agent">AI 智能体</label>
<select id="meta-agent" v-model="selectedAgentId" class="meta-input">
<option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)">
{{ agent.display_name || agent.name }}
</option>
</select>
<div class="meta-actions">
<button class="meta-btn meta-btn-secondary" @click="emit('connected', account)">暂时跳过</button>
<button class="meta-btn meta-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent">
{{ savingAgent ? '保存中…' : '分配智能体' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.meta-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.meta-modal-content {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 12px);
width: min(460px, calc(100vw - 32px));
padding: 24px;
}
.meta-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.meta-modal-header h3 {
margin: 0;
font-family: var(--font-display);
}
.meta-close-btn {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: var(--muted);
}
.meta-reconnect {
display: flex;
align-items: center;
gap: 12px;
margin: 0 0 16px;
padding: 12px;
border-radius: var(--radius-md, 8px);
background: var(--error-bg);
}
.meta-reconnect-text {
margin: 0;
flex: 1;
color: var(--error-color);
font-size: 14px;
line-height: 1.5;
}
.meta-intro {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.meta-intro-link {
/* --accent-ink, not --accent-solid: the latter is the lime fill and stays
lime in both themes, which is unreadable as text on a light background. */
color: var(--accent-ink);
text-decoration: underline;
font-weight: 600;
}
.meta-intro-link:hover {
text-decoration: none;
}
.meta-field {
margin-bottom: 12px;
}
.meta-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.meta-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
color: inherit;
font-size: 14px;
}
.meta-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.meta-btn {
padding: 9px 16px;
border-radius: var(--radius-btn, 8px);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--background-soft);
color: inherit;
}
.meta-btn-primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
border-color: transparent;
}
.meta-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.meta-signup {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.meta-signup-btn {
width: 100%;
padding: 12px;
}
.meta-link-btn {
background: none;
border: none;
color: var(--muted);
font-size: 13px;
cursor: pointer;
text-decoration: underline;
padding: 4px;
}
.meta-link-btn:hover {
color: inherit;
}
</style>
@@ -0,0 +1,60 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!-- The webhook address and verify token of a WhatsApp number or Messenger
Page connected from the customer's own Meta app. -->
<script setup lang="ts">
import { computed } from 'vue'
import type { ChannelAccount } from '@/services/channels'
import CopyField from './CopyField.vue'
const props = defineProps<{ account: ChannelAccount }>()
const hint = computed(() => {
if (props.account.channel_type === 'messenger') {
return '在你的 Meta 应用中,将以下信息添加到 Messenger → 设置 → Webhooks,然后将公共主页订阅消息事件。'
}
return props.account.webhook_auto_configured
? '该号码的消息会直接发送到 ChatterMate,无需额外配置。'
: '在你的 Meta 应用中,将以下信息添加到 WhatsApp → 配置,以便消息送达 ChatterMate。'
})
</script>
<template>
<div class="own-app-webhook">
<p class="own-app-hint">{{ hint }}</p>
<CopyField field-id="own-app-callback-url" label="回调 URL" name="回调 URL"
:value="account.webhook_url || ''" />
<CopyField field-id="own-app-verify-token" label="验证 Token" name="验证 Token"
:value="account.webhook_verify_token || ''" />
</div>
</template>
<style scoped>
.own-app-webhook {
margin-bottom: 16px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
}
.own-app-hint {
margin: 0 0 12px;
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
</style>
@@ -0,0 +1,245 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import channelsService, { type ChannelAccount } from '@/services/channels'
import { agentService } from '@/services/agent'
import type { Agent } from '@/types/agent'
const props = defineProps<{
existingAccount?: ChannelAccount | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'connected', account: ChannelAccount): void
}>()
const botToken = ref('')
const connecting = ref(false)
const account = ref<ChannelAccount | null>(props.existingAccount ?? null)
const agents = ref<Agent[]>([])
const selectedAgentId = ref('')
const savingAgent = ref(false)
onMounted(async () => {
try {
agents.value = await agentService.getOrganizationAgents()
selectedAgentId.value = String(
props.existingAccount?.agent_id || agents.value[0]?.id || '')
} catch (error) {
console.error('Error loading agents:', error)
}
})
const connect = async () => {
if (!botToken.value.trim()) {
toast.error('请粘贴来自 @BotFather 的机器人 Token')
return
}
try {
connecting.value = true
account.value = await channelsService.connectTelegram(botToken.value.trim())
toast.success(`已连接 ${account.value.display_name || 'Telegram 机器人'}`)
} catch (error: any) {
const detail = error?.response?.data?.detail || '连接 Telegram 机器人失败'
toast.error(detail)
} finally {
connecting.value = false
}
}
const saveAgent = async () => {
if (!account.value || !selectedAgentId.value) return
try {
savingAgent.value = true
const updated = await channelsService.setAccountAgent(account.value.id, selectedAgentId.value)
toast.success('智能体已分配 — 你的机器人已生效!')
emit('connected', updated)
} catch (error: any) {
toast.error(error?.response?.data?.detail || '分配智能体失败')
} finally {
savingAgent.value = false
}
}
</script>
<template>
<div class="tg-modal" @click.self="emit('close')">
<div class="tg-modal-content">
<div class="tg-modal-header">
<h3>连接 Telegram</h3>
<button class="tg-close-btn" @click="emit('close')">×</button>
</div>
<!-- Step 1: bot token -->
<div v-if="!account" class="tg-modal-body">
<ol class="tg-steps">
<li>在 Telegram 中打开 <strong>@BotFather</strong>,发送 <code>/newbot</code></li>
<li>按提示为你的机器人命名</li>
<li>复制机器人 Token 并粘贴到下方</li>
</ol>
<label class="tg-label" for="tg-token">机器人 Token</label>
<input
id="tg-token"
v-model="botToken"
type="password"
class="tg-input"
placeholder="123456789:AAF…"
name="tg-bot-token"
autocomplete="new-password"
@keyup.enter="connect"
/>
<div class="tg-actions">
<button class="tg-btn tg-btn-secondary" @click="emit('close')">取消</button>
<button class="tg-btn tg-btn-primary" :disabled="connecting" @click="connect">
{{ connecting ? '连接中…' : '连接' }}
</button>
</div>
</div>
<!-- Step 2: route to an agent -->
<div v-else class="tg-modal-body">
<p class="tg-connected-note">
<strong>{{ account.display_name }}</strong> 已连接。
选择由哪个 AI 智能体应答其消息:
</p>
<label class="tg-label" for="tg-agent">AI 智能体</label>
<select id="tg-agent" v-model="selectedAgentId" class="tg-input">
<option v-for="agent in agents" :key="String(agent.id)" :value="String(agent.id)">
{{ agent.display_name || agent.name }}
</option>
</select>
<div class="tg-actions">
<button class="tg-btn tg-btn-secondary" @click="emit('connected', account)">暂时跳过</button>
<button class="tg-btn tg-btn-primary" :disabled="savingAgent || !selectedAgentId" @click="saveAgent">
{{ savingAgent ? '保存中…' : '分配智能体' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tg-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.tg-modal-content {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg, 12px);
width: min(440px, calc(100vw - 32px));
padding: 24px;
}
.tg-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.tg-modal-header h3 {
margin: 0;
font-family: var(--font-display);
}
.tg-close-btn {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: var(--muted);
}
.tg-steps {
margin: 0 0 16px;
padding-left: 20px;
color: var(--muted);
font-size: 14px;
line-height: 1.7;
}
.tg-steps code {
font-family: var(--font-mono);
background: var(--background-mute);
padding: 1px 5px;
border-radius: 4px;
}
.tg-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.tg-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
color: inherit;
font-size: 14px;
}
.tg-connected-note {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
}
.tg-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.tg-btn {
padding: 9px 16px;
border-radius: var(--radius-btn, 8px);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--background-soft);
color: inherit;
}
.tg-btn-primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
border-color: transparent;
}
.tg-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,415 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import channelsService, {
type ChannelAccount,
type WhatsAppTemplate,
} from '@/services/channels'
import BaseModal from '@/components/common/BaseModal.vue'
import { templatePreviewText, templateKey } from '@/utils/whatsappTemplates'
import { languageLabel } from '@/utils/whatsappLanguages'
const props = defineProps<{
accounts: ChannelAccount[]
}>()
const emit = defineEmits<{ (e: 'close'): void }>()
const accountId = ref(props.accounts[0]?.id ?? '')
const templates = ref<WhatsAppTemplate[]>([])
const loading = ref(true)
const loadError = ref('')
/**
* Templates are written in Meta's Template Library, not here — it has ~150
* pre-written, pre-localised ones built to pass its own review.
*
* The link is fetched rather than built: it is scoped to this number's Business
* Account, and only the server can resolve which that is. It is fetched up
* front so the anchor is a real href by the time it is clicked — resolving it
* on click, after an await, would hand the browser a popup to block.
*/
const libraryUrl = ref('')
// Guards against a slow response for a previously selected number landing after
// a newer one and rendering its templates under the wrong account.
let loadToken = 0
// The link's own token: it is keyed to the account, not the list, so the two
// reload on different schedules and cannot invalidate each other.
let libraryToken = 0
const load = async () => {
const token = ++loadToken
loadError.value = ''
if (!accountId.value) {
templates.value = []
loading.value = false
return
}
loading.value = true
try {
const result = await channelsService.listWhatsAppTemplates(accountId.value)
if (token !== loadToken) return
templates.value = result
} catch (error: any) {
if (token !== loadToken) return
loadError.value = error?.response?.data?.detail || '无法加载模板'
templates.value = []
} finally {
if (token === loadToken) loading.value = false
}
}
/**
* Fetched alongside the list but deliberately not awaited with it: this costs a
* Graph call, and failing it must not take the list down with it. Without a URL
* the link simply doesn't render.
*
* Only ever called for a change of account. The link is derived from the
* account's Business Account, so re-resolving it when the list reloads would
* spend a Graph round trip to learn what we already know — and the list
* reloads on every "refresh" click.
*/
const loadLibraryUrl = async () => {
const token = ++libraryToken
libraryUrl.value = ''
if (!accountId.value) return
try {
const url = await channelsService.getWhatsAppTemplateLibraryUrl(accountId.value)
if (token === libraryToken) libraryUrl.value = url
} catch {
// Left empty: the list is still useful, and there is nothing to act on.
}
}
onMounted(() => {
load()
loadLibraryUrl()
})
watch(accountId, () => {
load()
loadLibraryUrl()
})
</script>
<template>
<BaseModal title="WhatsApp 模板" width="620px" @close="emit('close')">
<p class="wtm-intro">
客户 24 小时会话窗口关闭后,可通过模板重新发起会话。模板需在 WhatsApp Manager 中编写;
经 Meta 审核通过后会显示在此处,客服即可发送。
</p>
<label v-if="accounts.length > 1" class="wtm-field">
<span class="wtm-label">号码</span>
<select v-model="accountId" class="wtm-input">
<option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.display_name || account.external_account_id }}
</option>
</select>
</label>
<div class="wtm-body">
<div v-if="loading" class="wtm-skeleton" aria-live="polite" aria-busy="true">
<div v-for="n in 3" :key="n" class="wtm-skeleton-row"></div>
</div>
<div v-else-if="loadError" class="wtm-empty">
<font-awesome-icon icon="fa-solid fa-circle-exclamation" />
<p>{{ loadError }}</p>
</div>
<div v-else-if="templates.length === 0" class="wtm-empty">
<p>暂无模板。</p>
</div>
<ul v-else class="wtm-list">
<!-- Keyed on name+language: one name can have a row per language, and
Vue needs them distinct. -->
<li v-for="template in templates" :key="templateKey(template)" class="wtm-row">
<div class="wtm-row-main">
<div class="wtm-row-head">
<span class="wtm-name">{{ template.name }}</span>
<span v-if="template.status" class="wtm-pill" :class="template.status.toLowerCase()">
{{ template.status }}
</span>
</div>
<div class="wtm-row-meta">
<span v-if="template.category">{{ template.category }}</span>
<span v-if="template.language">· {{ languageLabel(template.language) }}</span>
</div>
<p class="wtm-row-body">{{ templatePreviewText(template) }}</p>
</div>
</li>
</ul>
</div>
<!-- Meta's Template Library is where these get written: ~150 pre-written,
pre-localised templates, already shaped to pass its review. -->
<div v-if="accountId && !loading" class="wtm-create-guide">
<h4 class="wtm-guide-title">添加模板</h4>
<ol class="wtm-steps">
<li>打开 WhatsApp Manager,进入<strong>模板库</strong>。</li>
<li>选择现成模板,或选择<strong>创建模板</strong>自行编写。</li>
<li>
待 Meta 审核通过后,返回此处并点击<button type="button" class="wtm-link" @click="load">刷新</button>。
</li>
</ol>
<a
v-if="libraryUrl"
class="wtm-btn wtm-btn-primary wtm-guide-link"
:href="libraryUrl"
target="_blank"
rel="noopener noreferrer"
>
打开 WhatsApp Manager
<font-awesome-icon icon="fa-solid fa-arrow-up-right-from-square" />
</a>
</div>
</BaseModal>
</template>
<style scoped>
.wtm-intro {
margin: 0 0 16px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.wtm-body {
flex: 1;
}
.wtm-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.wtm-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
}
.wtm-row-main {
min-width: 0;
}
.wtm-row-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wtm-name {
font-weight: 600;
font-size: 14px;
}
/* Neutral by default: Meta adds statuses over time (PENDING_DELETION, IN_APPEAL,
FLAGGED, ...) and an unrecognised one must still read as a pill, not vanish. */
.wtm-pill {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--c-neutral) 16%, transparent);
color: var(--c-neutral);
border: 1px solid color-mix(in srgb, var(--c-neutral) 35%, transparent);
}
.wtm-pill.approved {
background: color-mix(in srgb, var(--c-positive) 16%, transparent);
color: var(--c-positive);
border-color: color-mix(in srgb, var(--c-positive) 35%, transparent);
}
.wtm-pill.pending {
background: color-mix(in srgb, var(--c-warn) 16%, transparent);
color: var(--c-warn);
border-color: color-mix(in srgb, var(--c-warn) 35%, transparent);
}
/* Rejected, paused and disabled all mean "cannot send" */
.wtm-pill.rejected,
.wtm-pill.paused,
.wtm-pill.disabled {
background: color-mix(in srgb, var(--c-danger) 16%, transparent);
color: var(--c-danger);
border-color: color-mix(in srgb, var(--c-danger) 35%, transparent);
}
.wtm-row-meta {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.wtm-row-body {
margin: 6px 0 0;
font-size: 13px;
color: var(--muted);
line-height: 1.5;
word-break: break-word;
}
.wtm-field {
display: block;
margin-bottom: 12px;
}
.wtm-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.wtm-input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-btn, 8px);
background: var(--background-soft);
color: inherit;
font-size: 14px;
font-family: inherit;
}
.wtm-skeleton {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Mirrors .wtm-row, the row it stands in for: a real row is legible because of
its border, not its fill (--background-soft is #0E0F14 against a #0B0C10
modal — a contrast ratio of 1.02). Without the border this was rendering
perfectly and showing nothing, so the modal just looked blank until the
fetch returned. */
.wtm-skeleton-row {
height: 64px;
border-radius: var(--radius-btn, 8px);
border: 1px solid var(--border-color);
background: var(--o08);
animation: wtm-pulse 1.4s ease-in-out infinite;
}
/* Only down to 0.6: at 0.5 the trough fell back below the threshold where the
row reads as being there at all. */
@keyframes wtm-pulse {
50% {
opacity: 0.6;
}
}
@media (prefers-reduced-motion: reduce) {
.wtm-skeleton-row {
animation: none;
}
}
.wtm-empty {
text-align: center;
padding: 24px 8px;
color: var(--muted);
font-size: 14px;
}
.wtm-empty p {
margin: 0 0 12px;
}
.wtm-create-guide {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
}
.wtm-guide-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
.wtm-steps {
margin: 0 0 12px;
padding-left: 20px;
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
/* A button, because it acts on this page — styled as the inline link it reads as */
.wtm-link {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--c-info);
cursor: pointer;
text-decoration: underline;
}
.wtm-guide-link {
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.wtm-btn {
padding: 8px 14px;
border-radius: var(--radius-btn, 8px);
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border-color);
background: var(--background-soft);
color: inherit;
}
.wtm-btn-primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
border-color: transparent;
}
</style>
@@ -0,0 +1,438 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { useJiraTicket } from '@/composables/useJiraTicket'
const props = defineProps<{
chatId: string
initialSummary?: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'ticketCreated', ticketKey: string): void
}>()
const {
jiraConnected,
jiraLoading,
showTicketModal,
jiraProjects,
jiraIssueTypes,
jiraPriorities,
selectedProject,
selectedIssueType,
selectedPriority,
ticketSummary,
ticketDescription,
loadingProjects,
loadingIssueTypes,
loadingPriorities,
creatingTicket,
createdTicketKey,
priorityAvailable,
checkingPriorityAvailability,
isFormValid,
checkJiraStatus,
handleProjectChange,
handleIssueTypeChange,
openTicketModal,
closeTicketModal,
submitTicket
} = useJiraTicket()
// Initialize the modal
const initModal = async () => {
await openTicketModal(props.initialSummary)
}
// Handle close
const handleClose = () => {
closeTicketModal()
emit('close')
}
// Handle submit
const handleSubmit = async () => {
const ticketKey = await submitTicket(props.chatId)
if (ticketKey) {
emit('ticketCreated', ticketKey)
handleClose()
}
}
// Initialize on mount
initModal()
</script>
<template>
<div class="ticket-modal">
<div class="ticket-modal-content">
<div class="ticket-modal-header">
<h3>创建 Jira 问题</h3>
<button class="close-modal-btn" @click="handleClose">
<font-awesome-icon icon="fa-solid fa-times" />
</button>
</div>
<div v-if="jiraLoading" class="ticket-loading">
<font-awesome-icon icon="fa-solid fa-spinner" spin />
正在检查 Jira 连接…
</div>
<div v-else-if="!jiraConnected" class="ticket-not-connected">
<font-awesome-icon icon="fa-solid fa-exclamation-triangle" />
<p>Jira 尚未连接,请在设置中连接 Jira。</p>
<router-link to="/settings/integrations" class="connect-jira-btn">
连接 Jira
</router-link>
</div>
<div v-else class="ticket-form">
<div class="form-group">
<label for="ticket-project">项目 *</label>
<div v-if="loadingProjects" class="loading-indicator">
<font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载项目中…
</div>
<select
v-else
id="ticket-project"
v-model="selectedProject"
@change="handleProjectChange(selectedProject)"
>
<option value="">选择项目</option>
<option
v-for="project in jiraProjects"
:key="project.id"
:value="project.key"
>
{{ project.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="ticket-issue-type">问题类型 *</label>
<div v-if="loadingIssueTypes" class="loading-indicator">
<font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载问题类型中…
</div>
<select
v-else
id="ticket-issue-type"
v-model="selectedIssueType"
:disabled="!selectedProject"
@change="handleIssueTypeChange(selectedIssueType)"
>
<option value="">选择问题类型</option>
<option
v-for="issueType in jiraIssueTypes"
:key="issueType.id"
:value="issueType.id"
>
{{ issueType.name }}
</option>
</select>
</div>
<div v-if="selectedProject && selectedIssueType" class="form-group">
<div v-if="checkingPriorityAvailability" class="loading-indicator">
<font-awesome-icon icon="fa-solid fa-spinner" spin /> 正在检查优先级字段…
</div>
<template v-else-if="priorityAvailable">
<label for="ticket-priority">优先级</label>
<div v-if="loadingPriorities" class="loading-indicator">
<font-awesome-icon icon="fa-solid fa-spinner" spin /> 加载优先级中…
</div>
<select
v-else
id="ticket-priority"
v-model="selectedPriority"
>
<option value="">选择优先级</option>
<option
v-for="priority in jiraPriorities"
:key="priority.id"
:value="priority.id"
>
{{ priority.name }}
</option>
</select>
</template>
<div v-else class="priority-unavailable">
<font-awesome-icon icon="fa-solid fa-info-circle" />
此项目 / 问题类型不支持优先级字段
</div>
</div>
<div class="form-group">
<label for="ticket-summary">主题 *</label>
<input
type="text"
id="ticket-summary"
v-model="ticketSummary"
placeholder="输入问题主题"
>
</div>
<div class="form-group">
<label for="ticket-description">描述</label>
<textarea
id="ticket-description"
v-model="ticketDescription"
placeholder="输入问题描述"
rows="4"
></textarea>
</div>
<div class="ticket-actions">
<button class="cancel-btn" @click="handleClose">取消</button>
<button
class="create-btn"
@click="handleSubmit"
:disabled="!isFormValid || creatingTicket"
>
<font-awesome-icon v-if="creatingTicket" icon="fa-solid fa-spinner" spin />
{{ creatingTicket ? '创建中…' : '创建问题' }}
</button>
</div>
<div v-if="createdTicketKey" class="ticket-created">
<font-awesome-icon icon="fa-solid fa-check-circle" />
问题已创建:<strong>{{ createdTicketKey }}</strong>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ticket-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.ticket-modal-content {
background: var(--background-color);
border-radius: 8px;
width: 500px;
max-width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.ticket-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
}
.ticket-modal-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 18px;
}
.close-modal-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 18px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
width: 32px;
height: 32px;
}
.close-modal-btn:hover {
background: var(--background-mute);
color: var(--text-primary);
}
.ticket-loading,
.ticket-not-connected {
padding: 24px;
text-align: center;
color: var(--text-muted);
}
.ticket-loading svg,
.ticket-not-connected svg {
font-size: 24px;
margin-bottom: 16px;
display: block;
}
.ticket-not-connected svg {
color: var(--warning);
}
.connect-jira-btn {
display: inline-block;
margin-top: 16px;
padding: 8px 16px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border-radius: 4px;
text-decoration: none;
font-weight: 500;
transition: all 0.2s;
}
.connect-jira-btn:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.ticket-form {
padding: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--background-color);
color: var(--text-primary);
font-size: 14px;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--primary-color);
outline: none;
}
.form-group select:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.loading-indicator {
padding: 10px 0;
color: var(--text-muted);
font-size: 14px;
}
.loading-indicator svg {
margin-right: 8px;
}
.ticket-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.cancel-btn {
background: var(--background-mute);
color: var(--text-primary);
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
}
.create-btn {
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.create-btn:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.create-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
filter: none;
}
.ticket-created {
margin-top: 16px;
padding: 12px;
background: var(--success-light);
color: var(--success);
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.ticket-created svg {
font-size: 18px;
}
.priority-unavailable {
padding: 10px;
background-color: var(--background-mute);
border-radius: 4px;
color: var(--text-muted);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.priority-unavailable svg {
color: var(--warning);
font-size: 16px;
}
</style>
@@ -0,0 +1,205 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts" name="EnablePushPrompt">
import { ref, onMounted } from 'vue'
import { isShopifyEmbedded } from '@/pwa/register'
import { usePWAInstall } from '@/composables/usePWAInstall'
import { navIconSvg } from '@/components/layout/navIcons'
const emit = defineEmits<{
// Parent (DashboardLayout) owns the notifications composable instance and
// performs the actual permission request from this user gesture.
(e: 'enable'): void
}>()
const SNOOZE_KEY = 'cm-push-prompt-snoozed-at'
const SNOOZE_MS = 14 * 24 * 60 * 60 * 1000
const visible = ref(false)
const needsInstallFirst = ref(false)
const { isIOS, isStandalone } = usePWAInstall()
const isSnoozed = () => {
const at = Number(localStorage.getItem(SNOOZE_KEY) || 0)
return at > 0 && Date.now() - at < SNOOZE_MS
}
onMounted(() => {
if (isShopifyEmbedded() || isSnoozed()) return
if (isIOS && !isStandalone.value) {
// Safari tab on iOS: the Push API only exists once installed to Home Screen
needsInstallFirst.value = true
visible.value = true
return
}
if ('Notification' in window && Notification.permission === 'default') {
visible.value = true
}
})
const snooze = () => {
localStorage.setItem(SNOOZE_KEY, String(Date.now()))
visible.value = false
}
const enable = () => {
emit('enable')
// The browser permission dialog takes over from here
visible.value = false
}
</script>
<template>
<Transition name="push-prompt">
<div v-if="visible" class="push-prompt" role="dialog" aria-label="开启通知">
<div class="bell-tile" aria-hidden="true" v-html="navIconSvg('bell', 28)"></div>
<template v-if="needsInstallFirst">
<div class="prompt-title">安装后接收通知</div>
<div class="prompt-body">
请先把 ChatterMate 添加到主屏幕 — 在 iPhone 上,推送通知仅在已安装的应用中生效。
点按分享图标,然后选择“添加到主屏幕”。
</div>
<button type="button" class="secondary-btn" @click="snooze">知道了</button>
</template>
<template v-else>
<div class="prompt-title">客户需要你时及时获知</div>
<div class="prompt-body">
AI 客服转接会话时,我们会立即通知你 — 不让客户久等。
</div>
<button type="button" class="primary-btn" @click="enable">
开启通知
</button>
<button type="button" class="secondary-btn" @click="snooze">暂不</button>
</template>
</div>
</Transition>
</template>
<style scoped>
.push-prompt {
position: fixed;
right: 20px;
bottom: 20px;
z-index: var(--z-prompt);
width: 340px;
max-width: calc(100vw - 40px);
background: var(--bg2);
border: 1px solid var(--o10);
border-radius: var(--radius-card);
box-shadow: var(--shadow-lg);
padding: var(--space-lg);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.bell-tile {
width: 60px;
height: 60px;
border-radius: 18px;
background: var(--teal-bg);
border: 1px solid var(--teal-border);
color: var(--c-teal);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-md);
}
.prompt-title {
font-family: var(--font-display);
font-size: 19px;
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-display);
line-height: 1.2;
color: var(--text);
}
.prompt-body {
font-size: 13.5px;
color: var(--muted);
line-height: 1.5;
margin-top: 10px;
}
.primary-btn {
width: 100%;
height: 48px;
margin-top: var(--space-lg);
border: none;
border-radius: var(--radius-btn);
background: var(--accent-solid);
color: var(--on-accent-solid);
font-family: var(--font-sans);
font-size: 15px;
font-weight: var(--font-weight-bold);
cursor: pointer;
transition: filter var(--transition-fast);
}
.primary-btn:hover:not(:disabled) {
filter: brightness(1.05);
}
.secondary-btn {
margin-top: 12px;
background: none;
border: none;
color: var(--muted);
font-family: var(--font-sans);
font-size: 14px;
font-weight: var(--font-weight-medium);
cursor: pointer;
padding: 8px;
}
.secondary-btn:hover {
color: var(--text);
}
/* Mobile: bottom sheet over the nav, safe-area aware */
@media (max-width: 768px) {
.push-prompt {
left: 0;
right: 0;
bottom: 0;
width: 100%;
max-width: 100%;
border-radius: 24px 24px 0 0;
border-left: none;
border-right: none;
border-bottom: none;
padding-bottom: calc(var(--space-lg) + var(--safe-bottom));
}
}
.push-prompt-enter-active,
.push-prompt-leave-active {
transition: opacity var(--transition-normal), transform var(--transition-normal);
}
.push-prompt-enter-from,
.push-prompt-leave-to {
opacity: 0;
transform: translateY(16px);
}
</style>
@@ -0,0 +1,580 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { notificationService, type Notification } from '@/services/notification'
import { getNotificationIcon } from './notificationIcons'
import { conversationSessionUrl } from '@/pwa/pushContract'
const router = useRouter()
const props = defineProps<{
isOpen: boolean
}>()
const emit = defineEmits<{
close: [],
'notification-read': []
}>()
const notifications = ref<Notification[]>([])
const isLoading = ref(false)
const error = ref('')
const fetchNotifications = async () => {
try {
isLoading.value = true
notifications.value = await notificationService.getNotifications()
} catch (err) {
error.value = '通知加载失败'
console.error('Error fetching notifications:', err)
} finally {
isLoading.value = false
}
}
const markAsRead = async (id: number) => {
try {
await notificationService.markAsRead(id)
const notification = notifications.value.find(n => n.id === id)
if (notification) {
notification.is_read = true
emit('notification-read')
}
} catch (err) {
console.error('Error marking notification as read:', err)
}
}
// Chat notifications (new chat, transfer, assignment) carry the session id in
// their metadata; clicking one marks it read and deep-links to that
// conversation, closing the drawer.
const sessionIdOf = (notification: Notification): string | undefined => {
const id = notification.notification_metadata?.session_id
return id ? String(id) : undefined
}
const handleNotificationClick = async (notification: Notification) => {
if (!notification.is_read) {
await markAsRead(notification.id)
}
const sessionId = sessionIdOf(notification)
if (sessionId) {
emit('close')
router.push(conversationSessionUrl(sessionId))
}
}
const activeFilter = ref<'all' | 'unread'>('all')
const filteredNotifications = computed(() =>
activeFilter.value === 'unread'
? notifications.value.filter(n => !n.is_read)
: notifications.value
)
const hasUnread = computed(() => notifications.value.some(n => !n.is_read))
// One request that clears everything, not one per loaded row. The list is a
// 50-row page, so looping it could never clear a badge showing 99+ however many
// times you tapped.
const markAllRead = async () => {
try {
await notificationService.markAllAsRead()
notifications.value.forEach(n => { n.is_read = true })
emit('notification-read')
} catch (err) {
console.error('Error marking all notifications as read:', err)
error.value = '无法全部标为已读'
}
}
const deleteNotification = async (id: number) => {
const previous = notifications.value
// Optimistic: the row disappears on tap, and comes back if the call fails.
notifications.value = notifications.value.filter(n => n.id !== id)
try {
await notificationService.deleteNotification(id)
emit('notification-read')
} catch (err) {
console.error('Error deleting notification:', err)
notifications.value = previous
error.value = '无法删除该通知'
}
}
const clearAll = async () => {
const previous = notifications.value
notifications.value = []
try {
await notificationService.clearAll()
emit('notification-read')
} catch (err) {
console.error('Error clearing notifications:', err)
notifications.value = previous
error.value = '无法清空通知'
}
}
const formatTime = (timestamp: string): string => {
const date = new Date(timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
// Less than a minute
if (diff < 60000) {
return '刚刚'
}
// Less than an hour
if (diff < 3600000) {
const mins = Math.floor(diff / 60000)
return `${mins} 分钟前`
}
// Less than a day
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
return `${hours} 小时前`
}
// Show date
return date.toLocaleDateString()
}
onMounted(fetchNotifications)
</script>
<template>
<div class="notification-drawer" :class="{ open: isOpen }">
<div class="drawer-header">
<h3>通知</h3>
<div class="header-actions">
<button
class="refresh-button"
@click="fetchNotifications"
:disabled="isLoading"
:class="{ 'loading': isLoading }"
title="刷新"
>
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.3"/>
</svg>
</button>
<button class="close-button" @click="emit('close')" title="关闭">&times;</button>
</div>
</div>
<div class="drawer-filter">
<div class="filter-tabs">
<button class="filter-tab" :class="{ active: activeFilter === 'all' }" @click="activeFilter = 'all'">全部</button>
<button class="filter-tab" :class="{ active: activeFilter === 'unread' }" @click="activeFilter = 'unread'">未读</button>
</div>
<div class="filter-actions">
<button class="mark-all" :disabled="!hasUnread" @click="markAllRead">全部已读</button>
<button class="mark-all" :disabled="!notifications.length" @click="clearAll">清空</button>
</div>
</div>
<div class="drawer-content">
<div v-if="isLoading" class="state-message">
正在加载通知…
</div>
<div v-else-if="error" class="state-message">
{{ error }}
</div>
<div v-else-if="!filteredNotifications.length" class="empty-state">
<div class="empty-title">全部看完了</div>
<div class="empty-sub">暂无{{ activeFilter === 'unread' ? '未读' : '' }}通知。</div>
</div>
<div v-else class="notifications">
<div v-for="notification in filteredNotifications" :key="notification.id" class="notification-item"
:class="{ unread: !notification.is_read, linkable: sessionIdOf(notification) }"
@click="handleNotificationClick(notification)">
<span class="notification-icon-wrap">
<img v-if="getNotificationIcon(notification.type.toLowerCase())"
:src="getNotificationIcon(notification.type.toLowerCase())" class="notification-type-icon"
:alt="notification.type" />
</span>
<div class="notification-body">
<div class="notification-top">
<span class="notification-title">{{ notification.title }}</span>
<span class="notification-time">{{ formatTime(notification.created_at) }}</span>
</div>
<div class="notification-message">{{ notification.message }}</div>
</div>
<span class="unread-dot" :class="{ on: !notification.is_read }"></span>
<!-- .stop: the row itself deep-links to the conversation -->
<button
class="delete-notification"
:aria-label="`删除通知:${notification.title}`"
title="删除"
@click.stop="deleteNotification(notification.id)"
>&times;</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.notification-drawer {
position: fixed;
top: 0;
right: -380px;
width: 380px;
max-width: 90vw;
height: 100vh;
background: var(--bg2);
border-left: 1px solid var(--o08);
transition: right 0.3s ease;
z-index: var(--z-drawer);
display: flex;
flex-direction: column;
}
.notification-drawer.open {
right: 0;
/* Only while open. Parked at right:-380px the panel is off-screen, but a
shadow cast 20px leftward with a 50px blur still reaches ~45px back onto
the page, above the content — and this drawer is mounted by
DashboardLayout on every screen, so the band followed you everywhere.
Invisible on the dark theme, a grey edge on the light one. */
box-shadow: -20px 0 50px rgba(0, 0, 0, 0.4);
}
.drawer-header {
padding: 20px 20px 16px;
border-bottom: 1px solid var(--o07);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg2);
}
.drawer-header h3 {
margin: 0;
font-family: var(--font-display);
font-size: 18px;
font-weight: var(--font-weight-bold);
color: var(--text);
}
.header-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.refresh-button {
background: transparent;
border: 1px solid var(--o12);
border-radius: 8px;
cursor: pointer;
width: 30px;
height: 30px;
color: var(--muted);
transition: background-color 0.2s ease, color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.refresh-button:hover {
background: var(--o06);
color: var(--text);
}
.refresh-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.refresh-button.loading svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.close-button {
background: transparent;
border: 1px solid var(--o12);
font-size: 15px;
cursor: pointer;
color: var(--muted);
transition: background-color 0.2s ease, color 0.2s ease;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
.close-button:hover {
background: var(--o06);
color: var(--text);
}
/* Filter row */
.drawer-filter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px;
border-bottom: 1px solid var(--o07);
}
.filter-tabs {
display: flex;
gap: 4px;
padding: 3px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 9px;
}
.filter-tab {
background: transparent;
border: none;
cursor: pointer;
padding: 5px 13px;
border-radius: 6px;
color: var(--muted);
font-family: var(--font-sans);
font-size: 13px;
font-weight: var(--font-weight-medium);
transition: background-color 0.15s ease, color 0.15s ease;
}
.filter-tab.active {
background: var(--accent-bg-12);
color: var(--accent-ink);
}
.filter-actions {
display: flex;
align-items: center;
gap: 14px;
}
/* Sits over the row; the row itself is the deep-link, so the button stops
propagation. 32px keeps it thumb-reachable without crowding the text. */
.delete-notification {
flex-shrink: 0;
width: 32px;
height: 32px;
border: none;
background: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 20px;
line-height: 1;
cursor: pointer;
opacity: 0.55;
transition: opacity 0.15s ease, background-color 0.15s ease;
}
.delete-notification:hover,
.delete-notification:focus-visible {
opacity: 1;
background: var(--o07);
}
.mark-all {
background: none;
border: none;
cursor: pointer;
color: var(--accent-ink);
font-family: var(--font-sans);
font-size: 13px;
font-weight: var(--font-weight-medium);
transition: filter 0.15s ease, opacity 0.15s ease;
}
.mark-all:hover:not(:disabled) {
filter: brightness(1.1);
}
.mark-all:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.drawer-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--o05);
cursor: pointer;
transition: background-color 0.2s ease;
}
.notification-item:hover {
background: var(--o03);
}
/* Chat notifications deep-link to their conversation — hint it on hover. */
.notification-item.linkable {
position: relative;
}
.notification-item.linkable::after {
content: '打开 ›';
position: absolute;
right: 18px;
bottom: 10px;
font-size: 11px;
font-weight: var(--font-weight-medium);
color: var(--accent-ink);
opacity: 0;
transition: opacity 0.15s ease;
}
.notification-item.linkable:hover::after {
opacity: 1;
}
.notification-item.unread {
background: var(--accent-bg-06);
}
.notification-icon-wrap {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.notification-type-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
filter: var(--icon-filter, brightness(0) invert(1));
opacity: var(--icon-opacity, 0.55);
}
.notification-body {
flex: 1;
min-width: 0;
}
.notification-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.notification-title {
font-size: 14px;
font-weight: var(--font-weight-semibold);
color: var(--text2);
word-break: break-word;
}
.notification-time {
font-size: 11px;
color: var(--muted2);
flex-shrink: 0;
white-space: nowrap;
}
.notification-message {
font-size: 12.5px;
color: var(--muted);
line-height: 1.5;
margin-top: 3px;
word-break: break-word;
}
.unread-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
border-radius: 50%;
background: transparent;
margin-top: 6px;
}
.unread-dot.on {
background: var(--accent-solid);
}
.state-message {
padding: var(--space-md);
text-align: center;
color: var(--muted2);
}
.empty-state {
padding: 60px 24px;
text-align: center;
}
.empty-title {
font-size: 14px;
color: var(--muted2);
margin-bottom: 4px;
}
.empty-sub {
font-size: 12.5px;
color: var(--faint);
}
/* Mobile: full-screen panel per design */
@media (max-width: 768px) {
.notification-drawer {
width: 100%;
max-width: 100vw;
right: -100vw;
height: 100vh;
height: 100dvh;
border-left: none;
}
.notification-drawer.open {
right: 0;
}
.drawer-header {
padding-top: calc(20px + var(--safe-top));
}
.drawer-content {
padding-bottom: var(--safe-bottom);
}
}
</style>
@@ -0,0 +1,30 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import chatIcon from '@/assets/chat.svg'
import knowledgeIcon from '@/assets/knowledge.svg'
export const getNotificationIcon = (type: string): string => {
switch (type) {
case 'knowledge_processed':
case 'knowledge_failed':
return knowledgeIcon
case 'chat':
return chatIcon
default:
return ''
}
}
@@ -0,0 +1,182 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import type { InvestigationEvent } from '@/types/ticket'
const props = defineProps<{ event: InvestigationEvent }>()
const isExpanded = ref(false)
function copyQuery() {
if (!props.event.tool_input) return
navigator.clipboard
.writeText(props.event.tool_input)
.then(() => toast.success('查询已复制'))
.catch(() => {})
}
function formatTime(iso?: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
</script>
<template>
<div class="evidence-row" :class="{ failed: !!event.error }">
<button class="evidence-head" @click="isExpanded = !isExpanded">
<span class="tool-badge">{{ event.connector_name || 'MCP' }}</span>
<span class="tool-name">{{ event.tool_name }}</span>
<span v-if="event.error" class="error-tag">错误</span>
<span class="evidence-meta">
<span v-if="event.duration_ms != null" class="mono">{{ event.duration_ms }}ms</span>
<span class="mono">{{ formatTime(event.created_at) }}</span>
<span class="chevron">{{ isExpanded ? '▾' : '▸' }}</span>
</span>
</button>
<div v-if="isExpanded" class="evidence-body">
<template v-if="event.tool_input">
<div class="block-label-row">
<span class="block-label">查询</span>
<button class="copy-btn" @click="copyQuery">复制</button>
</div>
<pre class="code-block">{{ event.tool_input }}</pre>
</template>
<template v-if="event.error">
<div class="block-label">错误</div>
<pre class="code-block error">{{ event.error }}</pre>
</template>
<template v-else-if="event.tool_result">
<div class="block-label">结果</div>
<pre class="code-block">{{ event.tool_result }}</pre>
</template>
</div>
</div>
</template>
<style scoped>
.evidence-row {
border: 1px solid var(--o07);
border-radius: 10px;
background: var(--bg2);
overflow: hidden;
}
.evidence-row.failed {
border-color: color-mix(in srgb, var(--c-danger) 35%, transparent);
}
.evidence-head {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 11px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
}
.tool-badge {
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.05em;
color: var(--c-teal);
background: var(--teal-bg-10);
padding: 2px 7px;
border-radius: 6px;
flex-shrink: 0;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-name {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-tag {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--c-danger);
background: color-mix(in srgb, var(--c-danger) 12%, transparent);
padding: 2px 7px;
border-radius: 6px;
}
.evidence-meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 9px;
color: var(--faint);
font-size: 10.5px;
flex-shrink: 0;
}
.mono {
font-family: var(--font-mono);
}
.chevron {
color: var(--muted2);
}
.evidence-body {
padding: 0 11px 11px;
}
.block-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.block-label {
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--faint);
margin: 8px 0 5px;
}
.copy-btn {
font-size: 10.5px;
color: var(--accent-ink);
background: var(--accent-bg-08);
border: none;
padding: 2px 8px;
border-radius: 6px;
cursor: pointer;
}
.code-block {
margin: 0;
padding: 9px 11px;
background: var(--surface);
border: 1px solid var(--o07);
border-radius: 8px;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.55;
color: var(--text3);
white-space: pre-wrap;
word-break: break-word;
max-height: 260px;
overflow-y: auto;
}
.code-block.error {
color: var(--c-danger);
}
</style>
@@ -0,0 +1,200 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { InvestigationEvent, InvestigationHypothesis } from '@/types/ticket'
import { hypothesisMeta } from './ticketMeta'
import EvidenceCitationRow from './EvidenceCitationRow.vue'
const props = defineProps<{
hypothesis: InvestigationHypothesis
events: InvestigationEvent[]
}>()
const isExpanded = ref(false)
const meta = computed(() => hypothesisMeta(props.hypothesis.status))
const isTesting = computed(() => props.hypothesis.status === 'testing')
const evidence = computed(() =>
props.events.filter((e) => e.hypothesis_id === props.hypothesis.id && e.event_type === 'tool_call'),
)
const confidencePct = computed(() =>
props.hypothesis.confidence != null ? Math.round(props.hypothesis.confidence * 100) : null,
)
</script>
<template>
<div class="hypothesis-card" :id="`hypothesis-${hypothesis.idx}`">
<button class="hypo-head" @click="isExpanded = !isExpanded">
<span class="hypo-id mono">H{{ hypothesis.idx }}</span>
<span class="hypo-title">{{ hypothesis.title }}</span>
<span class="hypo-chip" :style="{ color: meta.color, borderColor: meta.color }">
<span v-if="isTesting" class="pulse-dot" :style="{ background: meta.color }"></span>
{{ meta.label }}
</span>
<span class="chevron">{{ isExpanded ? '▾' : '▸' }}</span>
</button>
<div v-if="confidencePct != null" class="confidence-row">
<div class="confidence-track">
<div
class="confidence-fill"
:style="{ width: `${confidencePct}%`, background: meta.color }"
></div>
</div>
<span class="confidence-value mono" :style="{ color: meta.color }">{{ (hypothesis.confidence ?? 0).toFixed(2) }}</span>
</div>
<div v-if="isExpanded" class="hypo-body">
<p v-if="hypothesis.rationale" class="hypo-text">
<span class="text-label">理由 · </span>{{ hypothesis.rationale }}
</p>
<p v-if="hypothesis.conclusion" class="hypo-text">
<span class="text-label">结论 · </span>{{ hypothesis.conclusion }}
</p>
<template v-if="evidence.length">
<div class="evidence-label">证据——{{ evidence.length }} 次工具调用</div>
<div class="evidence-list">
<EvidenceCitationRow v-for="event in evidence" :key="event.id" :event="event" />
</div>
</template>
<p v-else class="no-evidence">无工具证据——基于工单上下文的推理验证。</p>
</div>
</div>
</template>
<style scoped>
.hypothesis-card {
border: 1px solid var(--o08);
border-radius: 12px;
background: var(--surface);
padding: 11px 13px;
}
.hypo-head {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
padding: 0;
}
.hypo-id {
font-size: 11px;
font-weight: var(--font-weight-bold);
color: var(--muted2);
background: var(--o05);
border: 1px solid var(--o08);
padding: 2px 7px;
border-radius: 6px;
flex-shrink: 0;
}
.mono {
font-family: var(--font-mono);
}
.hypo-title {
font-size: 13px;
font-weight: var(--font-weight-semibold);
color: var(--text);
flex: 1;
min-width: 0;
}
.hypo-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em;
border: 1px solid;
padding: 2px 8px;
border-radius: 20px;
flex-shrink: 0;
}
.pulse-dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: hypo-pulse 1.3s ease-in-out infinite;
}
@keyframes hypo-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.chevron {
color: var(--muted2);
font-size: 11px;
flex-shrink: 0;
}
.confidence-row {
display: flex;
align-items: center;
gap: 9px;
margin-top: 9px;
}
.confidence-track {
flex: 1;
height: 4px;
border-radius: 3px;
background: var(--o07);
overflow: hidden;
}
.confidence-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.confidence-value {
font-size: 10.5px;
font-weight: var(--font-weight-semibold);
}
.hypo-body {
margin-top: 11px;
border-top: 1px solid var(--o06);
padding-top: 11px;
}
.hypo-text {
margin: 0 0 9px;
font-size: 12.5px;
line-height: 1.55;
color: var(--text3);
}
.text-label {
color: var(--faint);
font-size: 11px;
}
.evidence-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
margin: 11px 0 7px;
}
.evidence-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.no-evidence {
margin: 0;
font-size: 12px;
color: var(--faint);
}
</style>
@@ -0,0 +1,133 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
/**
* A note that steers the next investigation. Used by the L2 approval banner
* when rejecting a proposal, and by the investigation panel for a plain
* guided re-run — the two differ only in wording and button colour, so the
* autofocus, the keyboard shortcuts and the trim live here rather than twice.
*/
import { onMounted, ref } from 'vue'
withDefaults(
defineProps<{
placeholder: string
submitLabel: string
tone?: 'danger' | 'accent'
busy?: boolean
}>(),
{ tone: 'accent', busy: false },
)
const emit = defineEmits<{
(e: 'submit', note: string): void
(e: 'cancel'): void
}>()
const note = ref('')
const input = ref<HTMLTextAreaElement | null>(null)
onMounted(() => input.value?.focus())
// An empty note is allowed: re-running unchanged is a legitimate thing to
// want, and the reject flow has always permitted a reason-less rejection.
const submit = () => emit('submit', note.value.trim())
</script>
<template>
<div class="note-form">
<textarea
ref="input"
v-model="note"
class="note-input"
:placeholder="placeholder"
@keydown.esc.stop="emit('cancel')"
@keydown.enter.meta.stop.prevent="submit"
@keydown.enter.ctrl.stop.prevent="submit"
></textarea>
<slot />
<div class="note-actions">
<button type="button" class="cancel-btn" @click="emit('cancel')">取消</button>
<button
type="button"
class="submit-btn"
:class="tone"
:disabled="busy"
@click="submit"
>
{{ submitLabel }}
</button>
</div>
</div>
</template>
<style scoped>
.note-form {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 11px;
padding: 12px;
}
.note-input {
width: 100%;
min-height: 68px;
resize: vertical;
background: var(--bg2);
border: 1px solid var(--o10);
border-radius: 9px;
padding: 9px 11px;
color: var(--text);
font-size: 12.5px;
line-height: 1.5;
outline: none;
}
.note-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
margin-top: 10px;
}
.cancel-btn {
padding: 7px 13px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
border-radius: 9px;
font-size: 12.5px;
cursor: pointer;
}
.submit-btn {
padding: 7px 15px;
border: none;
border-radius: 9px;
font-size: 12.5px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.submit-btn.danger {
background: var(--c-danger);
color: var(--on-light, #fff);
}
.submit-btn.accent {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,134 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ticketService } from '@/services/tickets'
import { useTicketSocket } from '@/composables/useTicketSocket'
import TicketStatusBadge from './TicketStatusBadge.vue'
import TicketAiStateChip from './TicketAiStateChip.vue'
import type { Ticket } from '@/types/ticket'
const props = defineProps<{ sessionId: string }>()
const router = useRouter()
const ticket = ref<Ticket | null>(null)
async function load() {
ticket.value = props.sessionId
? await ticketService.getTicketBySession(props.sessionId)
: null
}
useTicketSocket((event) => {
if (ticket.value && event.ticket_id === ticket.value.id) load()
})
// Gentle refresh keeps the card honest if the ticket was just created by the
// chat AI mid-conversation.
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
load()
timer = setInterval(load, 20000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
watch(() => props.sessionId, load)
</script>
<template>
<div v-if="ticket" class="linked-ticket-card">
<div class="card-header">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="var(--accent-ink)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2 2 2 0 0 0 0-4z"></path>
</svg>
<span class="card-title">关联工单</span>
</div>
<div class="card-body">
<div class="chip-row">
<span class="ticket-number">{{ ticket.display_number }}</span>
<TicketStatusBadge :status="ticket.status" />
</div>
<div class="ticket-title">{{ ticket.title }}</div>
<TicketAiStateChip :state="ticket.ai_state" class="ai-line" />
<button class="view-btn" @click="router.push(`/tickets/${ticket.id}`)">
查看工单 →
</button>
</div>
</div>
</template>
<style scoped>
.linked-ticket-card {
background: var(--surface);
border: 1px solid var(--accent-border);
border-radius: 13px;
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
gap: 7px;
padding: 11px 15px;
background: var(--accent-bg-08);
border-bottom: 1px solid var(--o07);
}
.card-title {
font-family: var(--font-display);
font-weight: var(--font-weight-semibold);
font-size: 12.5px;
color: var(--accent-ink);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card-body {
padding: 15px;
}
.chip-row {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 9px;
}
.ticket-number {
font-family: var(--font-mono);
font-size: 12px;
color: var(--muted2);
}
.ticket-title {
font-size: 13.5px;
font-weight: var(--font-weight-semibold);
line-height: 1.4;
margin-bottom: 11px;
color: var(--text);
}
.ai-line {
margin-bottom: 14px;
display: inline-flex;
}
.view-btn {
width: 100%;
padding: 9px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 9px;
font-size: 12.5px;
font-weight: var(--font-weight-bold);
cursor: pointer;
}
</style>
@@ -0,0 +1,274 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import type { TicketActivity } from '@/types/ticket'
import { ticketInitials } from './ticketMeta'
defineProps<{
activities: TicketActivity[]
canComment: boolean
isSaving: boolean
/** Delivery path to the customer exists — shows the "Send to customer" toggle. */
canMessageCustomer?: boolean
}>()
const emit = defineEmits<{ (e: 'comment', body: string, isInternal: boolean): void }>()
const draft = ref('')
const customerVisible = ref(false)
function submit() {
if (!draft.value.trim()) return
emit('comment', draft.value, !customerVisible.value)
draft.value = ''
customerVisible.value = false
}
const timeAgo = (iso?: string | null) =>
iso ? formatDistanceToNow(new Date(iso), { addSuffix: true, locale: zhCN }) : ''
function actorName(activity: TicketActivity): string {
if (activity.actor_name) return activity.actor_name
if (activity.actor_type === 'ai') return 'ChatterMate AI'
if (activity.actor_type === 'customer') return '客户'
return '系统'
}
function avatarText(activity: TicketActivity): string {
if (activity.actor_type === 'ai') return 'AI'
if (activity.actor_type === 'system') return '◇'
return ticketInitials(actorName(activity))
}
</script>
<template>
<div class="activity-feed">
<div class="feed-header">动态</div>
<div class="feed-body">
<div v-if="!activities.length" class="feed-empty">暂无动态。</div>
<div v-for="activity in activities" :key="activity.id" class="feed-item">
<span
class="avatar"
:class="{ ai: activity.actor_type === 'ai', system: activity.actor_type === 'system' }"
>
{{ avatarText(activity) }}
</span>
<div class="item-main">
<div class="item-header">
<span class="author">{{ actorName(activity) }}</span>
<span v-if="activity.actor_type === 'ai'" class="ai-tag">AI</span>
<span
v-if="activity.activity_type === 'comment' && !activity.is_internal"
class="visible-tag"
>
已发送给客户
</span>
<span
v-if="activity.activity_type === 'customer_replied'
&& activity.activity_metadata?.channel === 'email'"
class="visible-tag"
>
通过邮件回复
</span>
<span class="time">{{ timeAgo(activity.created_at) }}</span>
</div>
<div class="item-text" :class="{ muted: activity.actor_type === 'system' }">
{{ activity.body || activity.activity_type.replace(/_/g, ' ') }}
</div>
</div>
</div>
</div>
<div v-if="canComment" class="composer">
<textarea
v-model="draft"
class="composer-input"
:placeholder="customerVisible ? '回复客户…' : '添加内部备注…'"
@keydown.meta.enter="submit"
@keydown.ctrl.enter="submit"
></textarea>
<div class="composer-actions">
<label v-if="canMessageCustomer" class="visibility-toggle">
<input v-model="customerVisible" type="checkbox" />
发送给客户
</label>
<span v-else class="no-customer-note">内部备注——未关联客户</span>
<button class="post-btn" :disabled="!draft.trim() || isSaving" @click="submit">
{{ isSaving ? '发布中…' : '评论' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.activity-feed {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 15px;
overflow: hidden;
}
.feed-header {
padding: 14px 20px;
border-bottom: 1px solid var(--o07);
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--faint);
}
.feed-body {
padding: 6px 20px;
max-height: 480px;
overflow-y: auto;
}
.feed-empty {
padding: 20px 0;
font-size: 13px;
color: var(--muted);
}
.feed-item {
display: flex;
gap: 12px;
padding: 13px 0;
border-bottom: 1px solid var(--o05);
}
.feed-item:last-child {
border-bottom: none;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--c-coral);
color: var(--on-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: var(--font-weight-bold);
flex-shrink: 0;
}
.avatar.ai {
border-radius: 8px;
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.avatar.system {
border-radius: 8px;
background: var(--o08);
color: var(--muted);
}
.item-main {
flex: 1;
min-width: 0;
}
.item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.author {
font-size: 13px;
font-weight: var(--font-weight-semibold);
color: var(--text2);
}
.ai-tag {
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: var(--font-weight-semibold);
color: var(--c-info);
background: color-mix(in srgb, var(--c-info) 14%, transparent);
padding: 1px 6px;
border-radius: 5px;
}
.visible-tag {
font-size: 10px;
color: var(--c-teal);
background: var(--teal-bg-10);
padding: 1px 7px;
border-radius: 5px;
}
.time {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--faint);
white-space: nowrap;
}
.item-text {
font-size: 13px;
color: var(--text3);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.item-text.muted {
color: var(--muted);
}
.composer {
padding: 14px 20px;
border-top: 1px solid var(--o07);
}
.composer-input {
width: 100%;
min-height: 58px;
resize: vertical;
background: var(--bg2);
border: 1px solid var(--o08);
border-radius: 10px;
padding: 9px 12px;
color: var(--text);
font-size: 13px;
line-height: 1.5;
outline: none;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 9px;
}
.visibility-toggle {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--muted);
cursor: pointer;
}
.no-customer-note {
font-size: 12px;
color: var(--faint);
}
.post-btn {
padding: 8px 16px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 9px;
font-size: 13px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.post-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,55 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { TicketAiState } from '@/types/ticket'
import { aiStateMeta } from './ticketMeta'
const props = defineProps<{ state?: TicketAiState | null }>()
const meta = computed(() => aiStateMeta(props.state))
</script>
<template>
<span class="ai-state" :style="{ '--chip-color': meta.color }">
<span class="dot" :class="{ pulse: meta.pulse }"></span>{{ meta.label }}
</span>
</template>
<style scoped>
.ai-state {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: var(--font-weight-medium);
color: var(--chip-color);
white-space: nowrap;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--chip-color);
}
.dot.pulse {
animation: ticket-pulse 1.4s ease-in-out infinite;
}
@keyframes ticket-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
</style>
@@ -0,0 +1,296 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { InvestigationHypothesis, TicketProposal } from '@/types/ticket'
import InvestigationNoteForm from './InvestigationNoteForm.vue'
const props = defineProps<{
proposal: TicketProposal
hypotheses: InvestigationHypothesis[]
canApprove: boolean
}>()
const emit = defineEmits<{
(e: 'approve'): void
(e: 'reject', reason: string, reinvestigate: boolean): void
}>()
const isRejecting = ref(false)
const reinvestigate = ref(true)
const isSubmitting = ref(false)
const bestHypothesis = computed(() => {
const validated = props.hypotheses.filter((h) => h.status === 'validated')
if (!validated.length) return null
return validated.reduce((a, b) => ((a.confidence ?? 0) >= (b.confidence ?? 0) ? a : b))
})
function jumpToHypothesis() {
if (!bestHypothesis.value) return
document
.getElementById(`hypothesis-${bestHypothesis.value.idx}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function approve() {
isSubmitting.value = true
emit('approve')
}
function submitReject(reason: string) {
isSubmitting.value = true
emit('reject', reason, reinvestigate.value)
}
</script>
<template>
<div class="approval-banner" :class="proposal.status">
<!-- PENDING -->
<template v-if="proposal.status === 'pending'">
<div class="banner-head">
<font-awesome-icon :icon="['fas', 'hourglass-half']" class="banner-icon pending" />
<div>
<div class="banner-title">AI 建议的解决方案——等待你审批</div>
<div class="banner-sub">
批准后将解决工单,并把下方消息发送给客户。ChatterMate 不会执行任何基础设施变更,修复仍由你的团队完成。
</div>
</div>
<span v-if="proposal.confidence != null" class="confidence-chip mono">
{{ proposal.confidence.toFixed(2) }}
</span>
</div>
<div class="proposal-body">
<div class="block-label">建议解决方案</div>
<p class="proposal-text">{{ proposal.summary }}</p>
<template v-if="proposal.customer_message">
<div class="block-label">给客户的消息</div>
<p class="proposal-text customer">{{ proposal.customer_message }}</p>
</template>
<button v-if="bestHypothesis" class="reasoning-link" @click="jumpToHypothesis">
推理:H{{ bestHypothesis.idx }} · 已验证
<template v-if="bestHypothesis.confidence != null">
· 置信度 {{ bestHypothesis.confidence.toFixed(2) }}
</template>
<font-awesome-icon :icon="['fas', 'arrow-down']" />
</button>
</div>
<div v-if="canApprove" class="banner-actions">
<template v-if="!isRejecting">
<button class="reject-btn" :disabled="isSubmitting" @click="isRejecting = true">
拒绝…
</button>
<button class="approve-btn" :disabled="isSubmitting" @click="approve">
<font-awesome-icon :icon="['fas', 'check']" />
批准并解决
</button>
</template>
<InvestigationNoteForm
v-else
placeholder="哪里不对?你的备注会指导下一轮调查…"
submit-label="拒绝方案"
tone="danger"
:busy="isSubmitting"
@cancel="isRejecting = false"
@submit="submitReject"
>
<label class="reinvestigate-row">
<input v-model="reinvestigate" type="checkbox" />
根据此反馈重新调查
</label>
</InvestigationNoteForm>
</div>
<div v-else class="banner-sub">
你没有审批工单上 AI 操作的权限。
</div>
</template>
<!-- DECIDED -->
<template v-else-if="proposal.status === 'approved'">
<div class="banner-head">
<font-awesome-icon :icon="['fas', 'circle-check']" class="banner-icon approved" />
<div>
<div class="banner-title">方案已批准</div>
<div class="banner-sub">
<template v-if="proposal.decided_by_name">由 {{ proposal.decided_by_name }} 批准——</template>
工单已解决并已通知客户。任何基础设施变更都由你的团队执行,而非 ChatterMate。
</div>
</div>
</div>
</template>
<template v-else-if="proposal.status === 'rejected'">
<div class="banner-head">
<font-awesome-icon :icon="['fas', 'circle-xmark']" class="banner-icon rejected" />
<div>
<div class="banner-title">方案已拒绝</div>
<div class="banner-sub">
<template v-if="proposal.decided_by_name">由 {{ proposal.decided_by_name }} 拒绝。</template>
<template v-if="proposal.reject_reason">“{{ proposal.reject_reason }}”</template>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.approval-banner {
border-radius: 14px;
padding: 16px 18px;
border: 1.5px solid var(--c-warn);
background: color-mix(in srgb, var(--c-warn) 8%, transparent);
}
.approval-banner.approved {
border-color: var(--c-positive);
background: color-mix(in srgb, var(--c-positive) 8%, transparent);
}
.approval-banner.rejected {
border-color: var(--o10);
background: var(--surface);
}
.banner-head {
display: flex;
align-items: flex-start;
gap: 11px;
}
.banner-icon {
font-size: 17px;
line-height: 1.3;
flex-shrink: 0;
margin-top: 1px;
}
/* Status colour comes from the design tokens so both themes stay in step —
an emoji glyph can't follow the palette. */
.banner-icon.pending {
color: var(--c-warn);
}
.banner-icon.approved {
color: var(--c-positive);
}
.banner-icon.rejected {
color: var(--c-danger);
}
.banner-title {
font-family: var(--font-display);
font-weight: var(--font-weight-bold);
font-size: 14.5px;
color: var(--text);
}
.banner-sub {
margin-top: 3px;
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
.confidence-chip {
margin-left: auto;
font-size: 11px;
font-weight: var(--font-weight-semibold);
color: var(--c-warn);
background: color-mix(in srgb, var(--c-warn) 14%, transparent);
padding: 3px 9px;
border-radius: 20px;
flex-shrink: 0;
}
.mono {
font-family: var(--font-mono);
}
.proposal-body {
margin-top: 13px;
}
.block-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--faint);
margin: 10px 0 5px;
}
.proposal-text {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
}
.proposal-text.customer {
color: var(--text3);
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 10px;
padding: 10px 12px;
}
.reasoning-link {
margin-top: 11px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--c-info);
background: color-mix(in srgb, var(--c-info) 10%, transparent);
border: none;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
}
.banner-actions {
margin-top: 14px;
}
.banner-actions > .approve-btn,
.banner-actions > .reject-btn {
margin-right: 0;
}
.approve-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 17px;
background: var(--c-positive);
color: var(--on-light, #04140d);
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: var(--font-weight-bold);
cursor: pointer;
}
.reject-btn {
padding: 9px 15px;
background: transparent;
border: 1px solid var(--o12);
color: var(--text3);
border-radius: 10px;
font-size: 13px;
cursor: pointer;
margin-right: 9px;
}
.approve-btn:disabled,
.reject-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.reinvestigate-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 9px;
font-size: 12px;
color: var(--text3);
cursor: pointer;
}
</style>
@@ -0,0 +1,718 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { toast } from 'vue-sonner'
import { mcpService } from '@/services/mcp'
import type { MCPTool, MCPToolReferences, MCPTransportType } from '@/types/mcp'
import {
CONNECTOR_GUIDANCE_EXAMPLE,
CONNECTOR_GUIDANCE_HINT,
CONNECTOR_GUIDANCE_LABEL,
DEFAULT_MCP_TIMEOUT,
SECRET_MASK,
clampMCPTimeout,
linesToRecord,
recordToLines,
} from '@/utils/mcp'
import GuidanceTextarea from '@/components/common/GuidanceTextarea.vue'
import grafanaLogo from '@/assets/grafana-logo.svg'
import elasticsearchLogo from '@/assets/elasticsearch-logo.svg'
import sentryLogo from '@/assets/sentry-logo.svg'
import cloudwatchLogo from '@/assets/cloudwatch-logo.svg'
const props = defineProps<{ selectedIds: number[] }>()
const emit = defineEmits<{ (e: 'update:selected-ids', ids: number[]): void }>()
const connectors = ref<MCPTool[]>([])
const isLoading = ref(true)
const loadError = ref<string | null>(null)
const isSaving = ref(false)
const showForm = ref(false)
// Which connector the form is editing, or null when it is creating one.
const editingId = ref<number | null>(null)
const deleteTarget = ref<MCPTool | null>(null)
const deleteRefs = ref<MCPToolReferences | null>(null)
const isDeleting = ref(false)
// One-click starting points for the common observability platforms. All
// values are placeholders the user edits — any other platform with an MCP
// server works through the Custom tile.
const PRESETS = [
{
key: 'grafana',
name: 'Grafana',
logo: grafanaLogo,
desc: '通过 Grafana 数据源查询 Loki、Prometheus、Elastic 与 CloudWatch',
transport: 'http' as MCPTransportType,
url: 'http://your-mcp-grafana-host:8000/mcp',
envLines: '',
headerLines: 'Authorization=Bearer <grafana-service-account-token>',
command: '',
args: '',
},
{
// Elastic's own MCP endpoint, served by Kibana. The old
// @elastic/mcp-server-elasticsearch npm package is deprecated and its last
// published version is the deprecated one, so npx would install a dead
// package. The API key needs the feature_agentBuilder.read privilege or
// Kibana answers 403. Self-hosters below 9.2 can run Elastic's Docker
// image in http mode and point this at their own host instead.
key: 'elasticsearch',
name: 'Elasticsearch',
logo: elasticsearchLogo,
desc: '通过 Agent Builder 查询索引、映射与日志(Elastic 9.2+ 或 Serverless)',
transport: 'http' as MCPTransportType,
url: 'https://your-kibana-host/api/agent_builder/mcp',
headerLines: 'Authorization=ApiKey <api-key>',
command: '',
args: '',
envLines: '',
},
{
key: 'sentry',
name: 'Sentry',
logo: sentryLogo,
desc: '错误、问题与链路追踪',
transport: 'http' as MCPTransportType,
url: 'https://mcp.sentry.dev/mcp',
headerLines: 'Authorization=Bearer <sentry-token>',
command: '',
args: '',
envLines: '',
},
{
key: 'cloudwatch',
name: 'CloudWatch',
logo: cloudwatchLogo,
desc: 'AWS 日志、指标与告警',
transport: 'stdio' as MCPTransportType,
url: '',
headerLines: '',
command: 'uvx',
args: 'awslabs.cloudwatch-mcp-server@latest',
envLines: 'AWS_ACCESS_KEY_ID=<key>\nAWS_SECRET_ACCESS_KEY=<secret>\nAWS_REGION=us-east-1',
},
]
const form = reactive({
name: '',
description: '',
transport: 'http' as MCPTransportType,
url: '',
headerLines: '',
command: '',
args: '',
envLines: '',
timeout: DEFAULT_MCP_TIMEOUT,
usageGuidance: '',
})
function applyPreset(preset: (typeof PRESETS)[0] | null) {
Object.assign(form, {
name: preset?.name || '',
description: preset?.desc || '',
transport: preset?.transport || 'http',
url: preset?.url || '',
headerLines: preset?.headerLines || '',
command: preset?.command || '',
args: preset?.args || '',
envLines: preset?.envLines || '',
timeout: DEFAULT_MCP_TIMEOUT,
// Never seeded from the preset: its `desc` is a one-line marketing
// subtitle, and shipping that as prompt text is the exact failure this
// field exists to fix.
usageGuidance: '',
})
editingId.value = null
showForm.value = true
}
/** Load an existing connector into the same form. Secret values arrive
* masked; leaving them alone keeps the stored credential. */
function startEdit(connector: MCPTool) {
Object.assign(form, {
name: connector.name,
description: connector.description || '',
transport: connector.transport_type,
url: connector.url || '',
headerLines: recordToLines(connector.headers),
command: connector.command || '',
args: (connector.args || []).join(' '),
envLines: recordToLines(connector.env_vars),
timeout: connector.timeout ?? DEFAULT_MCP_TIMEOUT,
usageGuidance: connector.usage_guidance || '',
})
editingId.value = connector.id
showForm.value = true
}
async function fetchConnectors() {
isLoading.value = true
try {
connectors.value = await mcpService.getOrganizationMCPTools(false)
loadError.value = null
} catch (err: any) {
loadError.value =
err.response?.data?.detail || '加载连接器失败——当前套餐可能不支持 MCP 工具。'
} finally {
isLoading.value = false
}
}
/** The form's fields as an API payload. Fields belonging to the other
* transport are cleared so a switched connector carries no stale command or
* URL alongside its new one. */
function formPayload() {
const isRemote = form.transport !== 'stdio'
return {
name: form.name.trim(),
// Sent even when empty: an omitted key cannot clear a stored description.
description: form.description.trim(),
transport_type: form.transport,
enabled: true,
url: isRemote ? form.url.trim() : '',
headers: isRemote ? linesToRecord(form.headerLines) : {},
command: isRemote ? '' : form.command.trim(),
args: isRemote ? [] : form.args.trim().split(/\s+/).filter(Boolean),
env_vars: isRemote ? {} : linesToRecord(form.envLines),
timeout: clampMCPTimeout(form.timeout),
usage_guidance: form.usageGuidance.trim() || null,
}
}
async function saveConnector() {
if (!form.name.trim()) return
isSaving.value = true
try {
if (editingId.value !== null) {
const updated = await mcpService.updateMCPTool(editingId.value, formPayload())
await fetchConnectors()
showForm.value = false
editingId.value = null
toast.success(`${updated.name} 已更新`)
return
}
const created = await mcpService.createMCPTool(formPayload())
await fetchConnectors()
// New investigation connectors are opted in immediately.
emit('update:selected-ids', [...props.selectedIds, created.id])
showForm.value = false
toast.success(`${created.name} 已连接`)
} catch (err: any) {
const fallback = editingId.value !== null
? '更新连接器失败'
: '创建连接器失败'
toast.error(err.response?.data?.detail || fallback)
} finally {
isSaving.value = false
}
}
function cancelForm() {
showForm.value = false
editingId.value = null
}
/** Ask what points at the connector before offering to delete it, so the
* confirmation can name what it is about to break. */
async function requestDelete(connector: MCPTool) {
deleteTarget.value = connector
deleteRefs.value = null
try {
deleteRefs.value = await mcpService.getMCPToolReferences(connector.id)
} catch {
// Non-fatal — confirm without the reference list rather than blocking.
}
}
async function deleteConnector() {
const target = deleteTarget.value
if (!target) return
isDeleting.value = true
try {
await mcpService.deleteMCPTool(target.id)
if (editingId.value === target.id) cancelForm()
await fetchConnectors()
// The API drops the id from the org's selection itself; mirror that
// locally only when this connector was actually selected, so deleting an
// unselected one costs no settings write.
if (props.selectedIds.includes(target.id)) {
emit('update:selected-ids', props.selectedIds.filter((id) => id !== target.id))
}
deleteTarget.value = null
toast.success(`${target.name} 已删除`)
} catch (err: any) {
toast.error(err.response?.data?.detail || '删除连接器失败')
} finally {
isDeleting.value = false
}
}
function toggleSelected(id: number, checked: boolean) {
const next = checked
? [...props.selectedIds, id]
: props.selectedIds.filter((existing) => existing !== id)
emit('update:selected-ids', next)
}
onMounted(fetchConnectors)
</script>
<template>
<div class="connectors">
<div class="preset-grid">
<button
v-for="preset in PRESETS"
:key="preset.key"
class="preset-tile"
@click="applyPreset(preset)"
>
<img :src="preset.logo" :alt="`${preset.name} 标志`" class="preset-logo" />
<span class="preset-name">{{ preset.name }}</span>
<span class="preset-desc">{{ preset.desc }}</span>
</button>
<button class="preset-tile custom" @click="applyPreset(null)">
<span class="preset-icon"><font-awesome-icon :icon="['fas', 'plus']" /></span>
<span class="preset-name">添加 MCP 连接器</span>
<span class="preset-desc">任何提供 MCP 服务器的平台——Splunk、Datadog、New Relic 或自建服务</span>
</button>
</div>
<div v-if="showForm" class="create-form">
<div v-if="editingId !== null" class="form-title">正在编辑 {{ form.name || '连接器' }}</div>
<div class="form-grid">
<label class="form-field">
<span class="field-label">名称</span>
<input v-model="form.name" class="field-input" placeholder="Grafana 生产环境" />
</label>
<label class="form-field">
<span class="field-label">传输方式</span>
<select v-model="form.transport" class="field-input">
<option value="http">HTTP(流式)</option>
<option value="sse">SSE</option>
<option value="stdio">STDIO(本地命令)</option>
</select>
</label>
<template v-if="form.transport !== 'stdio'">
<label class="form-field wide">
<span class="field-label">服务器 URL</span>
<input v-model="form.url" class="field-input mono" placeholder="https://host/mcp" />
</label>
<label class="form-field wide">
<span class="field-label">请求头(每行一条,KEY=value)</span>
<textarea v-model="form.headerLines" class="field-input mono" rows="2"></textarea>
<span v-if="editingId !== null" class="field-note">
已保存的值显示为 {{ SECRET_MASK }}——保持不变即可沿用现有凭据。
</span>
</label>
</template>
<template v-else>
<label class="form-field">
<span class="field-label">命令</span>
<input v-model="form.command" class="field-input mono" placeholder="npx" />
</label>
<label class="form-field">
<span class="field-label">参数</span>
<input v-model="form.args" class="field-input mono" placeholder="-y @elastic/mcp-server-elasticsearch" />
</label>
<label class="form-field wide">
<span class="field-label">环境变量(每行一条,KEY=value)</span>
<textarea v-model="form.envLines" class="field-input mono" rows="3"></textarea>
<span v-if="editingId !== null" class="field-note">
已保存的值显示为 {{ SECRET_MASK }}——保持不变即可沿用现有凭据。
</span>
</label>
</template>
<label class="form-field">
<span class="field-label">超时(秒)</span>
<input
v-model.number="form.timeout"
type="number"
min="1"
max="300"
class="field-input"
/>
<span class="field-note">握手与单次调用上限。首次启动较慢时可调大。</span>
</label>
<div class="form-field wide">
<GuidanceTextarea
v-model="form.usageGuidance"
:label="CONNECTOR_GUIDANCE_LABEL"
:hint="CONNECTOR_GUIDANCE_HINT"
:example="CONNECTOR_GUIDANCE_EXAMPLE"
:rows="5"
/>
</div>
</div>
<div class="form-actions">
<button class="cancel-btn" @click="cancelForm">取消</button>
<button class="connect-btn" :disabled="isSaving || !form.name.trim()" @click="saveConnector">
<template v-if="editingId !== null">{{ isSaving ? '保存中…' : '保存更改' }}</template>
<template v-else>{{ isSaving ? '连接中…' : '连接' }}</template>
</button>
</div>
</div>
<div v-if="isLoading" class="state-note">加载连接器…</div>
<div v-else-if="loadError" class="state-note">{{ loadError }}</div>
<div v-else-if="connectors.length" class="connector-list">
<div v-for="connector in connectors" :key="connector.id" class="connector-row">
<label class="connector-select">
<input
type="checkbox"
:checked="selectedIds.includes(connector.id)"
:disabled="!connector.enabled"
@change="toggleSelected(connector.id, ($event.target as HTMLInputElement).checked)"
/>
<div class="connector-info">
<div class="connector-name">
{{ connector.name }}
<span class="transport-tag mono">{{ connector.transport_type }}</span>
<span v-if="!connector.enabled" class="disabled-tag mono">已禁用</span>
<span v-if="connector.usage_guidance" class="guided-tag mono" title="AI 已为此数据源配置指导">已配置指导</span>
</div>
<div class="connector-sub mono">
{{ connector.url || [connector.command, ...(connector.args || [])].join(' ') }}
</div>
</div>
<span class="use-label">用于调查</span>
</label>
<div class="row-actions">
<button class="row-btn" @click="startEdit(connector)">编辑</button>
<button class="row-btn danger" @click="requestDelete(connector)">删除</button>
</div>
</div>
</div>
<div v-else class="state-note">
暂无连接器——请在上方连接一个,AI 才能从你的日志和指标中收集证据。
</div>
<div v-if="deleteTarget" class="confirm-overlay" @click.self="deleteTarget = null">
<div class="confirm-box">
<h4 class="confirm-title">删除 {{ deleteTarget.name }}?</h4>
<p class="confirm-text">
其保存的凭据会一并删除,且无法恢复。
</p>
<ul v-if="deleteRefs && (deleteRefs.agents.length || deleteRefs.used_in_investigations)" class="confirm-refs">
<li v-if="deleteRefs.used_in_investigations">
它已被选用于调查——删除后将取消选择。
</li>
<li v-if="deleteRefs.agents.length">
已关联到 {{ deleteRefs.agents.join(', ') }}——该关联将被移除。
</li>
</ul>
<div class="form-actions">
<button class="cancel-btn" @click="deleteTarget = null">取消</button>
<button class="danger-btn" :disabled="isDeleting" @click="deleteConnector">
{{ isDeleting ? '删除中…' : '删除连接器' }}
</button>
</div>
</div>
</div>
<div class="lock-note">
<font-awesome-icon :icon="['fas', 'lock']" />
只读——连接器仅挂载到<strong>调查智能体</strong>,绝不用在面向客户的聊天智能体上。
</div>
</div>
</template>
<style scoped>
.connectors {
display: flex;
flex-direction: column;
gap: 14px;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 10px;
}
.preset-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
padding: 13px 14px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 12px;
cursor: pointer;
text-align: left;
transition: border-color 0.15s;
}
.preset-tile:hover {
border-color: var(--accent-ink);
}
.preset-tile.custom {
border-style: dashed;
}
.preset-logo {
width: 22px;
height: 22px;
object-fit: contain;
}
.preset-icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
font-size: 16px;
color: var(--muted);
}
.preset-name {
font-family: var(--font-display);
font-weight: var(--font-weight-semibold);
font-size: 13px;
color: var(--text);
}
.preset-desc {
font-size: 10.5px;
line-height: 1.4;
color: var(--faint);
}
.create-form {
background: var(--surface);
border: 1px solid var(--o10);
border-radius: 13px;
padding: 15px 16px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 11px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-field.wide {
grid-column: 1 / -1;
}
.field-label {
font-size: 11px;
color: var(--faint);
}
.field-note {
font-size: 11px;
color: var(--muted2);
line-height: 1.45;
}
.field-input {
padding: 8px 11px;
background: var(--bg2);
border: 1px solid var(--o10);
border-radius: 9px;
color: var(--text);
font-size: 12.5px;
outline: none;
resize: vertical;
}
.mono {
font-family: var(--font-mono);
font-size: 11.5px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
margin-top: 12px;
}
.cancel-btn {
padding: 7px 13px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
border-radius: 9px;
font-size: 12.5px;
cursor: pointer;
}
.connect-btn {
padding: 7px 16px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 9px;
font-size: 12.5px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.connect-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.state-note {
font-size: 12.5px;
color: var(--muted);
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--o07);
border-radius: 11px;
}
.connector-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.connector-row {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 14px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 11px;
}
.connector-select {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.row-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.row-btn {
padding: 5px 11px;
background: var(--o05);
border: 1px solid var(--o10);
border-radius: 8px;
color: var(--muted);
font-size: 11.5px;
cursor: pointer;
}
.row-btn:hover {
color: var(--text);
}
.row-btn.danger:hover {
color: var(--c-danger);
border-color: var(--c-danger);
}
.form-title {
font-size: 12px;
color: var(--muted);
margin-bottom: 11px;
}
.confirm-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background: var(--scrim);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.confirm-box {
background: var(--bg2);
border: 1px solid var(--o10);
border-radius: 13px;
padding: 18px 20px;
max-width: 420px;
width: 100%;
}
.confirm-title {
font-family: var(--font-display);
font-size: 15px;
color: var(--text);
margin: 0 0 8px;
}
.confirm-text {
font-size: 12.5px;
color: var(--muted);
line-height: 1.5;
margin: 0;
}
.confirm-refs {
margin: 10px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--muted2);
line-height: 1.6;
}
.danger-btn {
padding: 7px 16px;
background: var(--c-danger);
color: var(--on-dark);
border: none;
border-radius: 9px;
font-size: 12.5px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.danger-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.connector-info {
flex: 1;
min-width: 0;
}
.connector-name {
font-size: 13px;
font-weight: var(--font-weight-semibold);
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.transport-tag {
font-size: 9.5px;
color: var(--c-teal);
background: var(--teal-bg-10);
padding: 1px 7px;
border-radius: 6px;
text-transform: uppercase;
}
.disabled-tag,
.guided-tag {
font-size: 9.5px;
background: var(--o05);
padding: 1px 7px;
border-radius: 6px;
}
.disabled-tag {
color: var(--faint);
}
.guided-tag {
color: var(--muted);
}
.connector-sub {
font-size: 10.5px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
.use-label {
font-size: 11px;
color: var(--muted2);
flex-shrink: 0;
}
.lock-note {
font-size: 11.5px;
color: var(--muted);
line-height: 1.5;
}
</style>
@@ -0,0 +1,425 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { ticketService } from '@/services/tickets'
import { peopleService } from '@/services/people'
import type { PersonListItem } from '@/types/people'
import type { TicketDetail, TicketPriority } from '@/types/ticket'
import { PRIORITIES, priorityMeta } from './ticketMeta'
const props = defineProps<{
open: boolean
// Present when creating from a conversation: enables the AI-drafted prefill.
sessionId?: string | null
sessionLabel?: string | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'created', detail: TicketDetail): void
}>()
const title = ref('')
const description = ref('')
const priority = ref<TicketPriority>('medium')
const fromConversation = ref(false)
const isDrafting = ref(false)
const isSubmitting = ref(false)
// Manual tickets have no conversation to deliver through — collecting an
// email gives notifications a direct-email path. Suggestions come from the
// org's identified People.
const customerEmail = ref('')
const customerName = ref('')
const customerSuggestions = ref<PersonListItem[]>([])
let suggestTimer: ReturnType<typeof setTimeout> | null = null
watch(customerEmail, (query) => {
if (suggestTimer) clearTimeout(suggestTimer)
const term = query.trim()
if (term.length < 2) {
customerSuggestions.value = []
return
}
const matched = customerSuggestions.value.find((p) => p.email === term)
if (matched && !customerName.value) customerName.value = matched.name || ''
suggestTimer = setTimeout(async () => {
try {
const response = await peopleService.listPeople({ search: term, page_size: 8 })
customerSuggestions.value = response.items.filter((p) => p.email)
} catch {
customerSuggestions.value = []
}
}, 300)
})
onBeforeUnmount(() => {
if (suggestTimer) clearTimeout(suggestTimer)
})
watch(
() => props.open,
(open) => {
if (open) {
title.value = ''
description.value = ''
priority.value = 'medium'
fromConversation.value = false
customerEmail.value = ''
customerName.value = ''
customerSuggestions.value = []
if (props.sessionId) useConversationDraft()
}
},
)
async function useConversationDraft() {
if (!props.sessionId) return
fromConversation.value = true
isDrafting.value = true
try {
const draft = await ticketService.draftFromSession(props.sessionId)
if (!title.value) title.value = draft.title
if (!description.value) description.value = draft.description
} catch {
// Draft is best-effort; the form still works blank.
} finally {
isDrafting.value = false
}
}
async function submit() {
if (!title.value.trim() || isSubmitting.value) return
isSubmitting.value = true
try {
const linkSession = fromConversation.value && props.sessionId
const detail = await ticketService.createTicket({
title: title.value.trim(),
description: description.value.trim() || undefined,
priority: priority.value,
session_id: linkSession ? props.sessionId! : undefined,
customer_email: !linkSession ? customerEmail.value.trim() || undefined : undefined,
customer_name: !linkSession ? customerName.value.trim() || undefined : undefined,
})
toast.success(`工单 ${detail.ticket.display_number} 已创建`)
emit('created', detail)
emit('close')
} catch (e: any) {
toast.error(e?.message || '创建工单失败')
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="modal-overlay" @click="emit('close')">
<div class="modal" @click.stop>
<div class="modal-header">
<div class="modal-title">新建工单</div>
<button class="close-btn" @click="emit('close')">×</button>
</div>
<div class="modal-body">
<div v-if="sessionId" class="source-toggle">
<button
class="source-option"
:class="{ active: !fromConversation }"
@click="fromConversation = false"
>
空白
</button>
<button
class="source-option"
:class="{ active: fromConversation }"
@click="useConversationDraft"
>
来自会话
</button>
</div>
<div v-if="fromConversation" class="draft-note">
<span class="draft-glyph">✎</span>
<span v-if="isDrafting">正在根据会话生成草稿…</span>
<span v-else>
已根据
<span class="mono">{{ sessionLabel || '该会话' }}</span>
生成草稿——可随意修改。
</span>
</div>
<label class="field-label">主题</label>
<input
v-model="title"
class="field-input"
placeholder="简要描述问题…"
maxlength="500"
/>
<label class="field-label">描述</label>
<textarea
v-model="description"
class="field-textarea"
placeholder="发生了什么?请说明报错、对客户的影响和复现步骤…"
></textarea>
<template v-if="!fromConversation">
<label class="field-label">客户邮箱 <span class="optional">(选填,用于通知客户)</span></label>
<input
v-model="customerEmail"
class="field-input"
type="email"
list="ticket-customer-suggestions"
placeholder="customer@company.com"
maxlength="320"
/>
<datalist id="ticket-customer-suggestions">
<option
v-for="person in customerSuggestions"
:key="person.id"
:value="person.email || ''"
>
{{ person.name || person.email }}
</option>
</datalist>
<template v-if="customerEmail.trim()">
<label class="field-label">客户名称 <span class="optional">(选填)</span></label>
<input
v-model="customerName"
class="field-input"
placeholder="Jane Doe"
maxlength="200"
/>
</template>
</template>
<label class="field-label">优先级</label>
<div class="priority-pills">
<button
v-for="p in PRIORITIES"
:key="p"
class="priority-pill"
:class="{ active: priority === p }"
:style="{ '--pill-color': priorityMeta(p).color }"
@click="priority = p"
>
<span class="dot"></span>{{ priorityMeta(p).label }}
</button>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="emit('close')">取消</button>
<button class="btn-primary" :disabled="!title.trim() || isSubmitting" @click="submit">
{{ isSubmitting ? '创建中…' : '创建工单' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: var(--scrim, rgba(4, 5, 8, 0.62));
backdrop-filter: blur(6px);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 8vh 20px 20px;
}
.modal {
width: 560px;
max-width: 100%;
background: var(--bg2);
border: 1px solid var(--o12);
border-radius: 18px;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--o07);
}
.modal-title {
font-family: var(--font-display);
font-weight: var(--font-weight-bold);
font-size: 17px;
color: var(--text);
}
.close-btn {
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
cursor: pointer;
font-size: 16px;
}
.modal-body {
padding: 22px;
}
.source-toggle {
display: flex;
gap: 5px;
padding: 3px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 10px;
margin-bottom: 18px;
}
.source-option {
flex: 1;
padding: 8px;
border: none;
border-radius: 7px;
font-size: 12.5px;
cursor: pointer;
background: transparent;
color: var(--muted);
}
.source-option.active {
background: var(--o08);
color: var(--text);
font-weight: var(--font-weight-semibold);
}
.draft-note {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 13px;
background: var(--accent-bg-08);
border: 1px solid var(--accent-border);
border-radius: 11px;
margin-bottom: 16px;
font-size: 12px;
color: var(--text3);
line-height: 1.4;
}
.draft-glyph {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 6px;
background: var(--accent-solid);
color: var(--on-accent-solid);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.mono {
font-family: var(--font-mono);
color: var(--text);
}
.field-label {
display: block;
font-size: 11.5px;
color: var(--faint);
margin-bottom: 6px;
}
.optional {
color: var(--muted2);
font-weight: var(--font-weight-normal);
}
.field-input,
.field-textarea {
width: 100%;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--o10);
border-radius: 10px;
color: var(--text);
font-size: 13.5px;
outline: none;
margin-bottom: 16px;
}
.field-textarea {
min-height: 96px;
resize: vertical;
line-height: 1.5;
}
.priority-pills {
display: flex;
gap: 8px;
}
.priority-pill {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
border-radius: 9px;
font-size: 12.5px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
border: 1px solid var(--o10);
background: var(--surface);
color: var(--muted);
}
.priority-pill .dot {
width: 6px;
height: 6px;
border-radius: 2px;
background: var(--pill-color);
}
.priority-pill.active {
border-color: var(--pill-color);
color: var(--pill-color);
background: var(--o05);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 22px;
border-top: 1px solid var(--o07);
background: var(--surface);
}
.btn-secondary {
padding: 9px 17px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--text);
border-radius: 9px;
font-size: 13px;
cursor: pointer;
}
.btn-primary {
padding: 9px 20px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 9px;
font-size: 13px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,889 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { toast } from 'vue-sonner'
import { dbConnectorService } from '@/services/tickets'
import type { DbConnector, DbConnectorTable } from '@/types/ticket'
const connectors = ref<DbConnector[]>([])
const isLoading = ref(true)
const loadError = ref<string | null>(null)
const showForm = ref(false)
// When set, the picker edits this saved connector instead of the draft form.
const editingConnector = ref<DbConnector | null>(null)
const form = reactive({
name: '',
engine: 'postgresql' as 'postgresql' | 'mysql',
host: '',
port: 5432,
database: '',
username: '',
password: '',
max_rows: 100,
// SSH tunnel — production databases are usually behind a bastion.
ssh_enabled: false,
ssh_host: '',
ssh_port: 22,
ssh_username: '',
ssh_auth: 'key' as 'key' | 'password',
ssh_password: '',
ssh_private_key: '',
ssh_private_key_passphrase: '',
})
// Only the tunnel fields the API accepts, with the chosen auth method.
function sshPayload() {
if (!form.ssh_enabled) return { ssh_enabled: false }
const base = {
ssh_enabled: true,
ssh_host: form.ssh_host.trim(),
ssh_port: form.ssh_port,
ssh_username: form.ssh_username.trim(),
}
return form.ssh_auth === 'password'
? { ...base, ssh_password: form.ssh_password || undefined }
: {
...base,
ssh_private_key: form.ssh_private_key || undefined,
ssh_private_key_passphrase: form.ssh_private_key_passphrase || undefined,
}
}
const isDiscovering = ref(false)
const discoveredTables = ref<DbConnectorTable[] | null>(null)
const discoverError = ref<string | null>(null)
const selectedTables = ref<Set<string>>(new Set())
const maskedColumns = ref<Set<string>>(new Set())
// { "schema.table": "customer_column" } — a scoped table only ever returns the
// ticket customer's own rows. Absent = readable in full.
const rowScope = ref<Record<string, string>>({})
const rowScopeKey = ref<'email' | 'phone'>('email')
const expandedTable = ref<string | null>(null)
const isSaving = ref(false)
const scopedTableCount = computed(() => Object.keys(rowScope.value).length)
function setRowScope(table: DbConnectorTable, column: string) {
const next = { ...rowScope.value }
if (column) next[tableKey(table)] = column
else delete next[tableKey(table)]
rowScope.value = next
}
const tablesBySchema = computed(() => {
const groups: Record<string, DbConnectorTable[]> = {}
for (const table of discoveredTables.value || []) {
;(groups[table.schema] ||= []).push(table)
}
return groups
})
function tableKey(table: DbConnectorTable): string {
return `${table.schema}.${table.table}`.toLowerCase()
}
function resetPicker() {
discoveredTables.value = null
discoverError.value = null
selectedTables.value = new Set()
maskedColumns.value = new Set()
rowScope.value = {}
rowScopeKey.value = 'email'
expandedTable.value = null
}
function openCreateForm() {
editingConnector.value = null
Object.assign(form, {
name: '', engine: 'postgresql', host: '', port: 5432,
database: '', username: '', password: '', max_rows: 100,
ssh_enabled: false, ssh_host: '', ssh_port: 22, ssh_username: '',
ssh_auth: 'key', ssh_password: '', ssh_private_key: '', ssh_private_key_passphrase: '',
})
resetPicker()
showForm.value = true
}
async function fetchConnectors() {
isLoading.value = true
try {
connectors.value = await dbConnectorService.list()
loadError.value = null
} catch (err: any) {
loadError.value = err.response?.data?.detail || '加载数据库连接器失败'
} finally {
isLoading.value = false
}
}
async function discover() {
isDiscovering.value = true
discoverError.value = null
try {
const result = editingConnector.value
? await dbConnectorService.test(editingConnector.value.id)
: await dbConnectorService.discover({
name: form.name, engine: form.engine, host: form.host, port: form.port,
database: form.database, username: form.username, password: form.password,
...sshPayload(),
})
if (!result.ok) {
discoverError.value = result.error || '连接失败'
discoveredTables.value = null
} else {
discoveredTables.value = result.tables
toast.success(`已连接——发现 ${result.tables.length} 张表`)
}
} catch (err: any) {
discoverError.value = err.response?.data?.detail || '连接失败'
} finally {
isDiscovering.value = false
}
}
function toggleTable(table: DbConnectorTable, checked: boolean) {
const next = new Set(selectedTables.value)
if (checked) {
next.add(tableKey(table))
} else {
next.delete(tableKey(table))
// Don't leave a scope rule behind on a table that is no longer allowed —
// re-adding it later would silently restore a filter nobody chose.
setRowScope(table, '')
}
selectedTables.value = next
}
function toggleMask(columnName: string, masked: boolean) {
const next = new Set(maskedColumns.value)
if (masked) next.add(columnName.toLowerCase())
else next.delete(columnName.toLowerCase())
maskedColumns.value = next
}
async function saveConnector() {
if (!selectedTables.value.size) {
toast.error('请至少选择一张表——否则无法查询')
return
}
isSaving.value = true
try {
const policy = {
allowed_tables: [...selectedTables.value],
masked_columns: [...maskedColumns.value],
row_scope: rowScope.value,
row_scope_key: rowScopeKey.value,
max_rows: form.max_rows,
}
if (editingConnector.value) {
await dbConnectorService.update(editingConnector.value.id, policy)
toast.success('连接器已更新')
} else {
await dbConnectorService.create({
name: form.name, engine: form.engine, host: form.host, port: form.port,
database: form.database, username: form.username, password: form.password,
enabled: true, ...sshPayload(), ...policy,
})
toast.success('数据库连接器已创建')
}
showForm.value = false
editingConnector.value = null
resetPicker()
await fetchConnectors()
} catch (err: any) {
toast.error(err.response?.data?.detail || '保存连接器失败')
} finally {
isSaving.value = false
}
}
async function editTables(connector: DbConnector) {
editingConnector.value = connector
form.max_rows = connector.max_rows
resetPicker()
selectedTables.value = new Set((connector.allowed_tables || []).map((t) => t.toLowerCase()))
maskedColumns.value = new Set((connector.masked_columns || []).map((c) => c.toLowerCase()))
rowScope.value = { ...(connector.row_scope || {}) }
rowScopeKey.value = connector.row_scope_key || 'email'
showForm.value = true
await discover()
}
async function toggleEnabled(connector: DbConnector, enabled: boolean) {
try {
await dbConnectorService.update(connector.id, { enabled })
await fetchConnectors()
} catch (err: any) {
toast.error(err.response?.data?.detail || '更新连接器失败')
}
}
async function removeConnector(connector: DbConnector) {
if (!confirm(`删除连接器“${connector.name}”?AI 将失去此数据库的访问权限。`)) return
try {
await dbConnectorService.remove(connector.id)
await fetchConnectors()
toast.success('连接器已删除')
} catch (err: any) {
toast.error(err.response?.data?.detail || '删除连接器失败')
}
}
onMounted(fetchConnectors)
</script>
<template>
<div class="db-connectors">
<div v-if="isLoading" class="state-note">加载中…</div>
<div v-else-if="loadError" class="state-note">{{ loadError }}</div>
<template v-else>
<div v-if="connectors.length" class="connector-list">
<div v-for="connector in connectors" :key="connector.id" class="connector-card">
<div class="connector-main">
<div class="connector-name">
{{ connector.name }}
<span class="engine-tag mono">{{ connector.engine }}</span>
<span
v-if="connector.last_test_ok != null"
class="test-tag mono"
:class="{ ok: connector.last_test_ok }"
>
{{ connector.last_test_ok ? '已连接' : '连接失败' }}
</span>
</div>
<div class="connector-sub mono">
{{ connector.username }}@{{ connector.host }}:{{ connector.port }}/{{ connector.database }}
· {{ (connector.allowed_tables || []).length }} 张表可用
· {{ (connector.masked_columns || []).length }} 列已脱敏
· {{ Object.keys(connector.row_scope || {}).length }} 张按客户限定
</div>
</div>
<label class="enable-toggle">
<input
type="checkbox"
:checked="connector.enabled"
@change="toggleEnabled(connector, ($event.target as HTMLInputElement).checked)"
/>
启用
</label>
<button class="small-btn" @click="editTables(connector)">编辑数据表</button>
<button class="small-btn danger" @click="removeConnector(connector)">删除</button>
</div>
</div>
<button v-if="!showForm" class="add-connector" @click="openCreateForm">
+ 连接数据库
</button>
<div v-if="showForm" class="create-form">
<template v-if="!editingConnector">
<div class="form-grid">
<label class="form-field">
<span class="field-label">名称</span>
<input v-model="form.name" class="field-input" placeholder="生产只读副本" />
</label>
<label class="form-field">
<span class="field-label">引擎</span>
<select
v-model="form.engine"
class="field-input"
@change="form.port = form.engine === 'mysql' ? 3306 : 5432"
>
<option value="postgresql">PostgreSQL</option>
<option value="mysql">MySQL</option>
</select>
</label>
<label class="form-field">
<span class="field-label">主机</span>
<input v-model="form.host" class="field-input mono" placeholder="db-replica.internal" />
</label>
<label class="form-field">
<span class="field-label">端口</span>
<input v-model.number="form.port" type="number" class="field-input mono" />
</label>
<label class="form-field">
<span class="field-label">数据库</span>
<input v-model="form.database" class="field-input mono" />
</label>
<label class="form-field">
<span class="field-label">用户名</span>
<input v-model="form.username" class="field-input mono" placeholder="readonly_user" />
</label>
<label class="form-field">
<span class="field-label">密码</span>
<input v-model="form.password" type="password" class="field-input mono" />
</label>
<label class="form-field">
<span class="field-label">单次查询最大行数</span>
<input v-model.number="form.max_rows" type="number" min="1" max="1000" class="field-input mono" />
</label>
</div>
<!-- SSH TUNNEL -->
<div class="ssh-block">
<label class="ssh-toggle">
<input v-model="form.ssh_enabled" type="checkbox" />
<span>
通过 SSH 隧道连接
<span class="ssh-hint">——适用于位于堡垒机 / 跳板机之后的数据库(生产环境常见)</span>
</span>
</label>
<div v-if="form.ssh_enabled" class="ssh-fields">
<p class="ssh-note">
上方的主机和端口将从 SSH 主机的网络中解析。
</p>
<div class="form-grid">
<label class="form-field">
<span class="field-label">SSH 主机</span>
<input v-model="form.ssh_host" class="field-input mono" placeholder="bastion.example.com" />
</label>
<label class="form-field">
<span class="field-label">SSH 端口</span>
<input v-model.number="form.ssh_port" type="number" class="field-input mono" />
</label>
<label class="form-field">
<span class="field-label">SSH 用户名</span>
<input v-model="form.ssh_username" class="field-input mono" placeholder="ec2-user" />
</label>
<label class="form-field">
<span class="field-label">认证方式</span>
<select v-model="form.ssh_auth" class="field-input">
<option value="key">私钥</option>
<option value="password">密码</option>
</select>
</label>
<label v-if="form.ssh_auth === 'password'" class="form-field wide">
<span class="field-label">SSH 密码</span>
<input v-model="form.ssh_password" type="password" class="field-input mono" />
</label>
<template v-else>
<label class="form-field wide">
<span class="field-label">私钥(PEM / OpenSSH)</span>
<textarea
v-model="form.ssh_private_key"
class="field-input mono key-input"
rows="4"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;…"
></textarea>
</label>
<label class="form-field">
<span class="field-label">私钥口令(选填)</span>
<input v-model="form.ssh_private_key_passphrase" type="password" class="field-input mono" />
</label>
</template>
</div>
</div>
</div>
<p class="tip-note">
提示:使用只读副本,并只为数据库用户授予 SELECT 权限——在 ChatterMate 自身的防护之外再加一道保险。
</p>
</template>
<div v-else class="editing-note">
正在编辑 <strong>{{ editingConnector.name }}</strong> 的表访问权限
</div>
<div class="discover-row">
<button
class="small-btn"
:disabled="isDiscovering"
@click="discover"
>
{{ isDiscovering ? '连接中…' : discoveredTables ? '重新测试连接' : '测试连接' }}
</button>
<span v-if="discoveredTables" class="discover-result">
<font-awesome-icon :icon="['fas', 'check']" />
发现 {{ discoveredTables.length }} 张表——请选择 AI 可查询的表
</span>
<span v-else-if="discoverError" class="discover-error">{{ discoverError }}</span>
</div>
<div v-if="discoveredTables && scopedTableCount" class="scope-key-row">
<label class="scope-label">
客户匹配依据
<select v-model="rowScopeKey" class="scope-select">
<option value="email">邮箱地址</option>
<option value="phone">手机号</option>
</select>
</label>
<span class="scope-key-hint">
<template v-if="rowScopeKey === 'email'">
工单客户的邮箱会与你为各表指定的列进行匹配,忽略大小写。
</template>
<template v-else>
工单客户的手机号会与你为各表指定的列精确匹配,请确保两边存储格式一致。
</template>
</span>
</div>
<!-- ALLOWLIST TREE -->
<div v-if="discoveredTables" class="table-tree">
<div v-for="(tables, schema) in tablesBySchema" :key="schema" class="schema-group">
<div class="schema-label mono">{{ schema }}</div>
<div v-for="table in tables" :key="tableKey(table)" class="table-row-wrap">
<div class="table-row">
<label class="table-check">
<input
type="checkbox"
:checked="selectedTables.has(tableKey(table))"
@change="toggleTable(table, ($event.target as HTMLInputElement).checked)"
/>
<span class="table-name mono">{{ table.table }}</span>
<span class="col-count">{{ table.columns.length }} 列</span>
<span v-if="rowScope[tableKey(table)]" class="scope-chip mono">
<font-awesome-icon :icon="['fas', 'user-shield']" />
{{ rowScope[tableKey(table)] }}
</span>
</label>
<button
v-if="selectedTables.has(tableKey(table))"
class="mask-toggle"
@click="expandedTable = expandedTable === tableKey(table) ? null : tableKey(table)"
>
{{ expandedTable === tableKey(table) ? '隐藏列' : '列脱敏' }}
</button>
</div>
<div v-if="expandedTable === tableKey(table)" class="column-list">
<div v-for="column in table.columns" :key="column.name" class="column-row">
<span class="column-name mono">{{ column.name }}</span>
<span class="column-type">{{ column.type }}</span>
<button
class="mask-pill"
:class="{ masked: maskedColumns.has(column.name.toLowerCase()) }"
@click="toggleMask(column.name, !maskedColumns.has(column.name.toLowerCase()))"
>
{{ maskedColumns.has(column.name.toLowerCase()) ? '已脱敏' : '可见' }}
</button>
</div>
<p class="mask-hint">
脱敏列在 AI 看到之前就会被隐藏——引用这些列的查询会被直接拒绝。脱敏按列名在此连接器内全局生效。
</p>
<div class="scope-row">
<label class="scope-label">
限定为该工单客户的数据
<select
class="scope-select mono"
:value="rowScope[tableKey(table)] || ''"
@change="setRowScope(table, ($event.target as HTMLSelectElement).value)"
>
<option value="">不限定——可读取全部行</option>
<option v-for="column in table.columns" :key="column.name" :value="column.name">
{{ column.name }}
</option>
</select>
</label>
<p class="mask-hint">
选择存放客户标识的列。AI 的查询会被改写为仅读取该客户的行——无法扩大范围,且未关联客户的工单完全无法查询此表。与具体客户无关的参考数据可不限定。
</p>
</div>
</div>
</div>
</div>
</div>
<div class="form-actions">
<button class="cancel-btn" @click="showForm = false; editingConnector = null">取消</button>
<button
v-if="discoveredTables"
class="save-btn"
:disabled="isSaving"
@click="saveConnector"
>
{{ isSaving ? '保存中…' : editingConnector ? '保存更改' : '保存连接器' }}
</button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.db-connectors {
display: flex;
flex-direction: column;
gap: 12px;
}
.state-note {
font-size: 12.5px;
color: var(--muted);
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--o07);
border-radius: 11px;
}
.connector-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.connector-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 12px;
}
.connector-main {
flex: 1;
min-width: 0;
}
.connector-name {
font-size: 13.5px;
font-weight: var(--font-weight-semibold);
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.engine-tag {
font-size: 9.5px;
color: var(--c-teal);
background: var(--teal-bg-10);
padding: 1px 7px;
border-radius: 6px;
text-transform: uppercase;
}
.test-tag {
font-size: 9.5px;
color: var(--c-danger);
background: color-mix(in srgb, var(--c-danger) 10%, transparent);
padding: 1px 7px;
border-radius: 6px;
}
.test-tag.ok {
color: var(--c-positive);
background: color-mix(in srgb, var(--c-positive) 10%, transparent);
}
.connector-sub {
margin-top: 3px;
font-size: 10.5px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono {
font-family: var(--font-mono);
}
.enable-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text3);
cursor: pointer;
flex-shrink: 0;
}
.small-btn {
padding: 6px 12px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--text);
border-radius: 8px;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
}
.small-btn.danger {
color: var(--c-danger);
border-color: color-mix(in srgb, var(--c-danger) 35%, transparent);
}
.small-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.add-connector {
align-self: flex-start;
padding: 9px 16px;
background: transparent;
border: 1.5px dashed var(--o14);
color: var(--muted);
border-radius: 11px;
font-size: 13px;
cursor: pointer;
}
.add-connector:hover {
border-color: var(--accent-ink);
color: var(--text);
}
.create-form {
background: var(--surface);
border: 1px solid var(--o10);
border-radius: 13px;
padding: 16px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 11px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.field-label {
font-size: 11px;
color: var(--faint);
}
.field-input {
padding: 8px 11px;
background: var(--bg2);
border: 1px solid var(--o10);
border-radius: 9px;
color: var(--text);
font-size: 12.5px;
outline: none;
}
.tip-note {
margin: 12px 0 0;
font-size: 11.5px;
color: var(--muted);
line-height: 1.5;
}
.ssh-block {
margin-top: 14px;
border-top: 1px solid var(--o07);
padding-top: 13px;
}
.ssh-toggle {
display: flex;
align-items: flex-start;
gap: 9px;
font-size: 13px;
color: var(--text3);
cursor: pointer;
}
.ssh-hint {
color: var(--faint);
font-size: 12px;
}
.ssh-fields {
margin-top: 12px;
}
.ssh-note {
margin: 0 0 10px;
font-size: 11.5px;
color: var(--faint);
}
.key-input {
resize: vertical;
line-height: 1.4;
}
.editing-note {
font-size: 13px;
color: var(--text3);
}
.discover-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 13px;
flex-wrap: wrap;
}
.discover-result {
font-size: 12px;
color: var(--c-positive);
}
.discover-error {
font-size: 12px;
color: var(--c-danger);
}
.table-tree {
margin-top: 13px;
border: 1px solid var(--o07);
border-radius: 11px;
max-height: 420px;
overflow-y: auto;
}
.schema-group {
padding: 6px 0;
}
.schema-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--faint);
padding: 6px 14px 4px;
}
.table-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 14px;
}
.table-row:hover {
background: var(--o03);
}
.table-check {
display: flex;
align-items: center;
gap: 9px;
cursor: pointer;
min-width: 0;
}
.table-name {
font-size: 12px;
color: var(--text);
}
.col-count {
font-size: 10.5px;
color: var(--faint);
}
.mask-toggle {
font-size: 11px;
color: var(--accent-ink);
background: var(--accent-bg-08);
border: none;
padding: 3px 9px;
border-radius: 7px;
cursor: pointer;
flex-shrink: 0;
}
.column-list {
margin: 4px 14px 10px 34px;
border-left: 2px solid var(--o07);
padding-left: 12px;
}
.column-row {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
}
.column-name {
font-size: 11.5px;
color: var(--text3);
min-width: 140px;
}
.column-type {
font-size: 10.5px;
color: var(--faint);
flex: 1;
}
.mask-pill {
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 9px;
border-radius: 20px;
border: 1px solid var(--o10);
background: transparent;
color: var(--muted2);
cursor: pointer;
}
.mask-pill.masked {
color: var(--c-warn);
border-color: var(--c-warn);
background: color-mix(in srgb, var(--c-warn) 10%, transparent);
}
.mask-hint {
margin: 8px 0 0;
font-size: 10.5px;
color: var(--faint);
line-height: 1.5;
}
.scope-row {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--o08);
}
.scope-label {
display: flex;
align-items: center;
gap: 9px;
font-size: 11.5px;
color: var(--muted2);
}
.scope-select {
flex: 1;
min-width: 0;
padding: 6px 9px;
background: var(--surface);
border: 1px solid var(--o10);
color: var(--text);
border-radius: 8px;
font-size: 11.5px;
}
.scope-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 7px;
border-radius: 20px;
font-size: 10px;
color: var(--c-teal);
background: color-mix(in srgb, var(--c-teal) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--c-teal) 30%, transparent);
}
.scope-key-row {
margin: 14px 0 4px;
padding: 11px 13px;
background: var(--o05);
border: 1px solid var(--o08);
border-radius: 10px;
}
.scope-key-hint {
display: block;
margin-top: 7px;
font-size: 10.5px;
color: var(--faint);
line-height: 1.5;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 9px;
margin-top: 14px;
}
.cancel-btn {
padding: 8px 14px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
border-radius: 9px;
font-size: 12.5px;
cursor: pointer;
}
.save-btn {
padding: 8px 17px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 9px;
font-size: 12.5px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.save-btn:disabled {
opacity: 0.5;
}
@media (max-width: 700px) {
.form-grid {
grid-template-columns: 1fr;
}
.connector-card {
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,83 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
defineProps<{ filtered: boolean }>()
const emit = defineEmits<{ (e: 'clear'): void; (e: 'create'): void }>()
</script>
<template>
<div class="empty-state">
<div class="glyph">∅</div>
<template v-if="filtered">
<div class="title">没有符合条件的工单</div>
<div class="hint">试试清除筛选条件,或新建一个工单。</div>
<button class="action" @click="emit('clear')">清除筛选</button>
</template>
<template v-else>
<div class="title">暂无工单</div>
<div class="hint">
聊天 AI 升级问题时,工单会出现在这里——你也可以自己创建。
</div>
<button class="action" @click="emit('create')">新建工单</button>
</template>
</div>
</template>
<style scoped>
.empty-state {
padding: 66px 20px;
text-align: center;
}
.glyph {
width: 46px;
height: 46px;
margin: 0 auto 16px;
border-radius: 14px;
border: 1.5px dashed var(--o16);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
color: var(--faint);
font-size: 18px;
}
.title {
font-family: var(--font-display);
font-weight: var(--font-weight-semibold);
font-size: 17px;
margin-bottom: 6px;
color: var(--text);
}
.hint {
font-size: 13px;
color: var(--muted);
margin-bottom: 18px;
}
.action {
padding: 8px 16px;
background: var(--o05);
border: 1px solid var(--o12);
color: var(--text);
border-radius: 9px;
font-size: 13px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.action:hover {
background: var(--o08);
}
</style>
@@ -0,0 +1,176 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { onMounted } from 'vue'
import type { TicketListFilters } from '@/types/ticket'
import { useUsers } from '@/composables/useUsers'
defineProps<{ filters: TicketListFilters }>()
const STATUS_PILLS = [
{ value: 'all', label: '全部' },
{ value: 'open', label: '待处理' },
{ value: 'awaiting', label: '等待中' },
{ value: 'breaching', label: 'SLA 超时' },
{ value: 'resolved', label: '已解决' },
]
const AI_PILLS = [
{ value: 'all', label: '全部 AI', color: 'var(--muted)' },
{ value: 'investigating', label: '调查中', color: 'var(--c-info)' },
{ value: 'awaiting', label: '等待人工', color: 'var(--c-warn)' },
{ value: 'human', label: '人工处理', color: 'var(--c-neutral)' },
{ value: 'resolved', label: 'AI 已解决', color: 'var(--c-positive)' },
]
const { users, fetchUsers } = useUsers()
onMounted(() => {
fetchUsers().catch(() => {})
})
</script>
<template>
<div class="filter-bar">
<div class="pill-group">
<button
v-for="pill in STATUS_PILLS"
:key="pill.value"
class="pill"
:class="{ active: filters.status === pill.value }"
@click="filters.status = pill.value"
>
{{ pill.label }}
</button>
</div>
<select v-model="filters.priority" class="filter-select" aria-label="按优先级筛选">
<option value="all">优先级 · 全部</option>
<option value="urgent">紧急</option>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
<select v-model="filters.assignee" class="filter-select" aria-label="按负责人筛选">
<option value="all">负责人 · 全部</option>
<option value="unassigned">未分配 / AI</option>
<option v-for="user in users" :key="user.id" :value="user.id">
{{ user.full_name || user.email }}
</option>
</select>
<div class="pill-group">
<button
v-for="pill in AI_PILLS"
:key="pill.value"
class="pill with-dot"
:class="{ active: filters.ai === pill.value }"
@click="filters.ai = pill.value"
>
<span class="dot" :style="{ background: pill.color }"></span>{{ pill.label }}
</button>
</div>
<div class="search-wrap">
<span class="search-glyph"></span>
<input v-model="filters.search" class="search-input" placeholder="搜索工单…" />
</div>
<select v-model="filters.sort" class="filter-select" aria-label="工单排序">
<option value="updated">排序 · 更新时间</option>
<option value="created">最新创建</option>
<option value="priority">优先级</option>
</select>
</div>
</template>
<style scoped>
.filter-bar {
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.pill-group {
display: flex;
gap: 4px;
padding: 3px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 10px;
}
.pill {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
border-radius: 7px;
font-size: 12.5px;
font-weight: var(--font-weight-medium);
cursor: pointer;
background: transparent;
color: var(--muted);
}
.pill.active {
background: var(--o10);
color: var(--text);
font-weight: var(--font-weight-semibold);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.filter-select {
padding: 8px 11px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 9px;
color: var(--text);
font-size: 12.5px;
cursor: pointer;
}
.search-wrap {
position: relative;
margin-left: auto;
}
.search-glyph {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
width: 11px;
height: 11px;
border: 1.5px solid var(--faint);
border-radius: 50%;
}
.search-input {
width: 190px;
padding: 8px 12px 8px 30px;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 9px;
color: var(--text);
font-size: 12.5px;
outline: none;
}
.search-input:focus {
border-color: var(--o16);
}
</style>
@@ -0,0 +1,340 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { InvestigationDetail } from '@/types/ticket'
import HypothesisCard from './HypothesisCard.vue'
import InvestigationNoteForm from './InvestigationNoteForm.vue'
const props = defineProps<{
investigation: InvestigationDetail
/** Whether this user can start another run right now. */
canReinvestigate?: boolean
/** Why they can't, shown as a tooltip on the disabled button. */
reinvestigateBlockedReason?: string | null
/** Gates the deep link to settings, which needs manage_organization. */
canEditSettings?: boolean
}>()
const emit = defineEmits<{ (e: 'reinvestigate', note: string): void }>()
// A guided re-run belongs here rather than only in the L2 approval banner:
// below autonomy 2 no proposal is ever created, so that banner never renders
// and an L1 operator had no way to correct a run short of editing the ticket
// description (#318).
const isNoting = ref(false)
function submitRerun(note: string) {
isNoting.value = false
emit('reinvestigate', note)
}
const run = computed(() => props.investigation.run)
const isActive = computed(() => ['pending', 'running'].includes(run.value?.status || ''))
const RUN_STATUS_META: Record<string, { label: string; color: string }> = {
pending: { label: '排队中', color: 'var(--c-neutral)' },
running: { label: '调查中', color: 'var(--c-info)' },
completed: { label: '已完成', color: 'var(--c-positive)' },
failed: { label: '失败', color: 'var(--c-danger)' },
cancelled: { label: '已取消', color: 'var(--c-neutral)' },
budget_exceeded: { label: '部分完成 · 预算', color: 'var(--c-warn)' },
}
const statusMeta = computed(
() => RUN_STATUS_META[run.value?.status || ''] || RUN_STATUS_META.pending,
)
// "What the AI is doing now": the newest event, phrased for humans.
const tickerLine = computed(() => {
if (!isActive.value) return null
const events = props.investigation.events
const latest = events[events.length - 1]
if (!latest) return '正在启动调查…'
if (latest.event_type === 'phase') return latest.label
return `正在查询 ${latest.connector_name || '连接器'} · ${latest.tool_name}`
})
const toolCallCount = computed(
() => props.investigation.events.filter((e) => e.event_type === 'tool_call').length,
)
// Compact token total for the run header, e.g. "1.2k tokens". Only shown once
// the run has recorded usage.
const tokenLabel = computed(() => {
const total = (run.value?.input_tokens || 0) + (run.value?.output_tokens || 0)
if (!total) return ''
return total >= 1000 ? `${(total / 1000).toFixed(1)}k tokens` : `${total} tokens`
})
// How many of the run's tool calls failed. Surfaced because a verdict resting
// on failed queries is worth nothing, however confident it reads (#318).
const erroredCalls = computed(() => run.value?.connector_status?.tool_calls_failed || 0)
const totalCalls = computed(() => run.value?.connector_status?.tool_calls || 0)
const allCallsFailed = computed(() => totalCalls.value > 0 && erroredCalls.value === totalCalls.value)
// A run that finished with fewer MCP connectors than configured produced its
// answer without the evidence those tools were meant to gather. So did a run
// whose connectors all came up but whose tools the provider refused — that one
// reports every connector loaded, so it needs its own check (#303).
const connectorWarning = computed(() => {
const status = run.value?.connector_status
if (!status) return null
const missingConnectors = status.loaded < status.configured
const refusedTools = !!status.provider_errors?.length
// A connector can come up, load its tools, and then error on every query.
// That reports loaded === configured with no provider errors, so it needs
// its own check or the run renders as healthy (#318).
if (!missingConnectors && !refusedTools && !erroredCalls.value) return null
return status
})
</script>
<template>
<div v-if="run" class="investigation-panel">
<div class="panel-head">
<div class="head-left">
<span class="panel-title">AI 调查</span>
<span class="run-chip" :style="{ color: statusMeta.color, borderColor: statusMeta.color }">
<span v-if="isActive" class="pulse-dot" :style="{ background: statusMeta.color }"></span>
{{ statusMeta.label }}
</span>
</div>
<div class="head-meta mono">
<span>{{ investigation.hypotheses.length }} 个假设</span>
<span>·</span>
<span>{{ toolCallCount }}/{{ run.max_tool_calls || '—' }} 次工具调用</span>
<span v-if="run.llm_calls">· {{ run.llm_calls }} 次 LLM 调用</span>
<span v-if="tokenLabel">· {{ tokenLabel }}</span>
<span v-if="run.model_name">· {{ run.model_name }}</span>
</div>
</div>
<div v-if="tickerLine" class="ticker">
<span class="ticker-dot"></span>
{{ tickerLine }}
</div>
<div v-if="run.status === 'failed' && run.error" class="run-error">
{{ run.error }}
</div>
<div v-if="connectorWarning" class="connector-warning">
<div v-if="connectorWarning.loaded < connectorWarning.configured">
仅使用了 {{ connectorWarning.loaded }} / {{ connectorWarning.configured }} 个已配置连接器——结论可能缺少证据。
</div>
<div v-else>
连接器已就绪,但其工具无法使用——结论可能缺少证据。
</div>
<ul v-if="connectorWarning.failed.length" class="connector-warning__list">
<li v-for="f in connectorWarning.failed" :key="f.name">
<strong>{{ f.name }}</strong>:{{ f.error }}
</li>
</ul>
<ul v-if="connectorWarning.provider_errors?.length" class="connector-warning__list">
<li v-for="reason in connectorWarning.provider_errors" :key="reason">{{ reason }}</li>
</ul>
<div v-if="erroredCalls" class="connector-warning__calls">
{{ erroredCalls }} / {{ totalCalls }} 次工具调用出错<template
v-if="allCallsFailed"
>——未采集到任何数据,此处结论均无证据支撑</template>。
</div>
</div>
<div v-if="investigation.hypotheses.length" class="hypothesis-list">
<HypothesisCard
v-for="hypothesis in investigation.hypotheses"
:key="hypothesis.id"
:hypothesis="hypothesis"
:events="investigation.events"
/>
</div>
<p v-else-if="!isActive" class="empty-note">本次调查未生成假设。</p>
<div v-if="canReinvestigate" class="panel-foot">
<button
v-if="!isNoting"
class="rerun-btn"
:disabled="!!reinvestigateBlockedReason"
:title="reinvestigateBlockedReason || '重新调查,并备注告诉 AI 这次要怎么做'"
@click="isNoting = true"
>
<font-awesome-icon :icon="['fas', 'rotate-right']" />
带指导重新调查…
</button>
<template v-else>
<InvestigationNoteForm
placeholder="这次希望 AI 怎么做?例如“订单号字段是 fields.order_ref,不是 order_id——请从 3 日 14:00 UTC 起搜索 app-logs-*。”"
submit-label="重新调查"
@cancel="isNoting = false"
@submit="submitRerun"
/>
<p class="rerun-scope">
仅对本次调查生效。若希望每次都告知 AI,请在连接器的
<router-link v-if="canEditSettings" to="/settings/ticketing">工单设置</router-link>
<span v-else>工单设置</span> 中配置。
</p>
</template>
</div>
</div>
</template>
<style scoped>
.panel-foot {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--o07);
}
.rerun-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
background: transparent;
border: 1px solid var(--o12);
color: var(--text3);
border-radius: 10px;
font-size: 12.5px;
cursor: pointer;
}
.rerun-btn:hover:not(:disabled) {
color: var(--text);
}
.rerun-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.rerun-scope {
margin: 8px 0 0;
font-size: 11.5px;
color: var(--muted);
line-height: 1.5;
}
.connector-warning__calls {
margin-top: 6px;
font-weight: var(--font-weight-semibold);
}
.investigation-panel {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 15px;
padding: 16px 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.head-left {
display: flex;
align-items: center;
gap: 10px;
}
.panel-title {
font-family: var(--font-display);
font-weight: var(--font-weight-bold);
font-size: 14.5px;
color: var(--text);
}
.run-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em;
border: 1px solid;
padding: 2px 9px;
border-radius: 20px;
}
.pulse-dot,
.ticker-dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: inv-pulse 1.3s ease-in-out infinite;
}
.ticker-dot {
background: var(--c-info);
flex-shrink: 0;
}
@keyframes inv-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.head-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 10.5px;
color: var(--faint);
}
.mono {
font-family: var(--font-mono);
}
.ticker {
margin-top: 12px;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 12px;
background: color-mix(in srgb, var(--c-info) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--c-info) 30%, transparent);
border-radius: 10px;
font-size: 12px;
color: var(--text3);
font-family: var(--font-mono);
}
.run-error {
margin-top: 12px;
padding: 9px 12px;
background: color-mix(in srgb, var(--c-danger) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--c-danger) 35%, transparent);
border-radius: 10px;
font-size: 12px;
color: var(--c-danger);
}
.connector-warning {
margin-top: 12px;
padding: 9px 12px;
background: color-mix(in srgb, var(--c-warn) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--c-warn) 35%, transparent);
border-radius: 10px;
font-size: 12px;
color: var(--c-warn);
}
.connector-warning__list {
margin: 6px 0 0;
padding-left: 18px;
}
.connector-warning__list li {
word-break: break-word;
}
.hypothesis-list {
margin-top: 13px;
display: flex;
flex-direction: column;
gap: 9px;
}
.empty-note {
margin: 12px 0 0;
font-size: 12.5px;
color: var(--faint);
}
</style>
@@ -0,0 +1,48 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { TicketPriority } from '@/types/ticket'
import { priorityMeta } from './ticketMeta'
const props = defineProps<{ priority: TicketPriority }>()
const meta = computed(() => priorityMeta(props.priority))
</script>
<template>
<span class="ticket-priority" :style="{ '--chip-color': meta.color }">
<span class="dot"></span>{{ meta.label }}
</span>
</template>
<style scoped>
.ticket-priority {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: var(--font-weight-semibold);
color: var(--chip-color);
white-space: nowrap;
}
.dot {
width: 6px;
height: 6px;
border-radius: 2px;
background: var(--chip-color);
}
</style>
@@ -0,0 +1,381 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { RcaDocument } from '@/types/ticket'
const props = withDefaults(
defineProps<{
rca: RcaDocument
canManage: boolean
canNotify: boolean
/** A save/send is in flight — both actions lock so nothing sends twice. */
isBusy?: boolean
}>(),
{ isBusy: false },
)
const emit = defineEmits<{
(e: 'save-draft', customerSummary: string): void
(e: 'send-customer', customerSummary: string): void
}>()
const customerDraft = ref(props.rca.customer_summary || '')
watch(
() => props.rca.customer_summary,
(next) => {
customerDraft.value = next || ''
},
)
interface Section {
id: string
label: string
present: boolean
}
const sections = computed<Section[]>(() => [
{ id: 'rca-summary', label: '摘要', present: !!props.rca.summary },
{ id: 'rca-impact', label: '影响', present: !!props.rca.impact },
{ id: 'rca-timeline', label: '时间线', present: !!props.rca.timeline?.length },
{ id: 'rca-log', label: '调查日志', present: !!props.rca.investigation_log },
{ id: 'rca-factors', label: '促成因素', present: !!props.rca.contributing_factors?.length },
{ id: 'rca-conclusion', label: '结论', present: !!props.rca.conclusion },
{ id: 'rca-remediation', label: '修复措施', present: !!props.rca.remediation },
{ id: 'rca-prevention', label: '预防措施', present: !!props.rca.prevention },
{ id: 'rca-customer', label: '客户摘要', present: true },
])
function jumpTo(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
</script>
<template>
<div class="rca-doc">
<div class="rca-head">
<div>
<div class="rca-title">根因分析</div>
<div class="rca-byline">
由 ChatterMate AI 生成
<template v-if="rca.reviewed_by_name"> · 审核人 {{ rca.reviewed_by_name }}</template>
<template v-else> · 尚未审核</template>
<span class="version mono">v{{ rca.version }}</span>
<span v-if="rca.is_partial" class="partial-tag">部分完成——已达预算上限</span>
</div>
</div>
<div v-if="rca.confidence != null" class="rca-confidence mono">
置信度 {{ rca.confidence.toFixed(2) }}
</div>
</div>
<div class="rca-grid">
<nav class="rca-toc">
<button
v-for="section in sections.filter((s) => s.present)"
:key="section.id"
class="toc-link"
@click="jumpTo(section.id)"
>
{{ section.label }}
</button>
</nav>
<div class="rca-body">
<section v-if="rca.summary" id="rca-summary">
<h4 class="section-label">摘要</h4>
<p class="section-text">{{ rca.summary }}</p>
</section>
<section v-if="rca.impact" id="rca-impact">
<h4 class="section-label">影响</h4>
<p class="section-text">{{ rca.impact }}</p>
</section>
<section v-if="rca.timeline?.length" id="rca-timeline">
<h4 class="section-label">时间线</h4>
<div class="timeline">
<div v-for="(entry, i) in rca.timeline" :key="i" class="timeline-row">
<span class="timeline-time mono">{{ entry.time }}</span>
<span class="timeline-event">{{ entry.event }}</span>
</div>
</div>
</section>
<section v-if="rca.investigation_log" id="rca-log">
<h4 class="section-label">调查日志</h4>
<p class="section-text">{{ rca.investigation_log }}</p>
</section>
<section v-if="rca.contributing_factors?.length" id="rca-factors">
<h4 class="section-label">促成因素</h4>
<ul class="factor-list">
<li v-for="(factor, i) in rca.contributing_factors" :key="i">{{ factor }}</li>
</ul>
</section>
<section v-if="rca.conclusion" id="rca-conclusion">
<h4 class="section-label">结论</h4>
<p class="section-text conclusion">{{ rca.conclusion }}</p>
</section>
<section v-if="rca.remediation" id="rca-remediation">
<h4 class="section-label">修复措施</h4>
<p class="section-text">{{ rca.remediation }}</p>
</section>
<section v-if="rca.prevention" id="rca-prevention">
<h4 class="section-label">预防措施</h4>
<p class="section-text">{{ rca.prevention }}</p>
</section>
<section id="rca-customer">
<h4 class="section-label">客户摘要</h4>
<div class="customer-card">
<textarea
v-if="canManage"
v-model="customerDraft"
class="customer-editor"
placeholder="给客户的通俗摘要…"
></textarea>
<p v-else class="section-text">{{ rca.customer_summary || '—' }}</p>
<div v-if="canManage" class="customer-actions">
<span v-if="!canNotify" class="no-channel-note">
没有客户联系渠道——请关联会话或添加客户邮箱后再发送。
</span>
<button
class="draft-btn"
:disabled="props.isBusy"
@click="emit('save-draft', customerDraft)"
>
保存草稿
</button>
<button
class="send-btn"
:disabled="props.isBusy || !canNotify || !customerDraft.trim()"
@click="emit('send-customer', customerDraft)"
>
{{ props.isBusy ? '发送中…' : '发送给客户' }}
</button>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<style scoped>
.rca-doc {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 15px;
padding: 18px 20px;
}
.rca-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 15px;
}
.rca-title {
font-family: var(--font-display);
font-weight: var(--font-weight-bold);
font-size: 15px;
color: var(--text);
}
.rca-byline {
margin-top: 3px;
font-size: 11.5px;
color: var(--faint);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.version {
color: var(--muted2);
background: var(--o05);
border: 1px solid var(--o08);
padding: 1px 6px;
border-radius: 5px;
font-size: 10px;
}
.partial-tag {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--c-warn);
background: color-mix(in srgb, var(--c-warn) 12%, transparent);
padding: 2px 7px;
border-radius: 6px;
}
.rca-confidence {
font-size: 11px;
color: var(--muted2);
flex-shrink: 0;
}
.mono {
font-family: var(--font-mono);
}
.rca-grid {
display: grid;
grid-template-columns: 148px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.rca-toc {
position: sticky;
top: 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
.toc-link {
text-align: left;
padding: 5px 9px;
background: transparent;
border: none;
border-radius: 7px;
color: var(--muted);
font-size: 11.5px;
cursor: pointer;
}
.toc-link:hover {
background: var(--o05);
color: var(--text);
}
.rca-body {
display: flex;
flex-direction: column;
gap: 17px;
min-width: 0;
}
.section-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--faint);
margin: 0 0 7px;
}
.section-text {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--text3);
white-space: pre-wrap;
word-break: break-word;
}
.section-text.conclusion {
color: var(--text);
}
.timeline {
display: flex;
flex-direction: column;
gap: 6px;
}
.timeline-row {
display: flex;
gap: 12px;
font-size: 12.5px;
}
.timeline-time {
color: var(--muted2);
font-size: 11px;
flex-shrink: 0;
min-width: 92px;
padding-top: 1px;
}
.timeline-event {
color: var(--text3);
line-height: 1.5;
}
.factor-list {
margin: 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.7;
color: var(--text3);
}
.customer-card {
border: 1.5px solid var(--c-teal);
background: var(--teal-bg-10, color-mix(in srgb, var(--c-teal) 8%, transparent));
border-radius: 12px;
padding: 13px 14px;
}
.customer-editor {
width: 100%;
min-height: 88px;
resize: vertical;
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 9px;
padding: 10px 12px;
color: var(--text);
font-size: 13px;
line-height: 1.55;
outline: none;
}
.customer-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 9px;
margin-top: 10px;
flex-wrap: wrap;
}
.no-channel-note {
margin-right: auto;
font-size: 11px;
color: var(--faint);
}
.draft-btn {
padding: 7px 13px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--text);
border-radius: 9px;
font-size: 12px;
cursor: pointer;
}
.send-btn {
padding: 7px 15px;
background: var(--c-teal);
color: var(--on-light, #04140d);
border: none;
border-radius: 9px;
font-size: 12px;
font-weight: var(--font-weight-bold);
cursor: pointer;
}
.send-btn:disabled,
.draft-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 900px) {
.rca-grid {
grid-template-columns: 1fr;
}
.rca-toc {
position: static;
flex-direction: row;
flex-wrap: wrap;
}
}
</style>
@@ -0,0 +1,466 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { Ticket } from '@/types/ticket'
import { formatSlaCountdown, ticketInitials } from './ticketMeta'
import { useUsers } from '@/composables/useUsers'
const props = defineProps<{
ticket: Ticket
linkedSessionIds: string[]
canManage: boolean
}>()
const emit = defineEmits<{
(e: 'assign', userId: string | null): void
(e: 'set-customer', email: string, name?: string): void
}>()
const router = useRouter()
const { users, fetchUsers } = useUsers()
const isPickingAssignee = ref(false)
const isEditingCustomer = ref(false)
const customerEmailDraft = ref('')
const customerNameDraft = ref('')
function startCustomerEdit() {
customerEmailDraft.value = props.ticket.customer?.email || ''
customerNameDraft.value = props.ticket.customer?.full_name || ''
isEditingCustomer.value = true
}
function saveCustomer() {
if (!customerEmailDraft.value.trim()) return
emit('set-customer', customerEmailDraft.value, customerNameDraft.value)
isEditingCustomer.value = false
}
onMounted(() => {
if (props.canManage) fetchUsers().catch(() => {})
})
const sla = computed(() => formatSlaCountdown(props.ticket.sla_due_at, props.ticket.resolved_at))
const firstResponseMet = computed(() => !!props.ticket.first_response_at)
const assigneeName = computed(
() => props.ticket.assignee?.full_name || props.ticket.assignee_name || null,
)
// CSAT is asked on close, through the linked conversation's rating prompt, so
// a ticket with no conversation is never asked at all. A reopened-then-closed
// ticket is asked again, hence the timestamp comparison rather than a plain
// "has a score" check — the newest ask is the one being waited on.
const csat = computed(() => {
const { csat_score: score, csat_requested_at: asked, csat_responded_at: answered } = props.ticket
const awaitingAnswer = !!asked && (!answered || new Date(asked) > new Date(answered))
if (score != null && !awaitingAnswer) {
return {
state: 'scored' as const,
score,
label: `${score}/5`,
color: score >= 4 ? 'var(--c-positive)' : score >= 3 ? 'var(--c-warn)' : 'var(--c-danger)',
}
}
if (awaitingAnswer) {
return { state: 'pending' as const, score: 0, label: '待评价', color: 'var(--muted2)' }
}
return { state: 'not-requested' as const, score: 0, label: '未邀请评价', color: 'var(--faint)' }
})
function openConversation() {
if (props.linkedSessionIds.length) {
router.push({ path: '/conversations', query: { session: props.linkedSessionIds[0] } })
}
}
function pickAssignee(userId: string | null) {
isPickingAssignee.value = false
emit('assign', userId)
}
</script>
<template>
<aside class="side-panel">
<div class="panel-card">
<div class="card-label">负责人</div>
<div class="assignee-row">
<span class="avatar" :class="{ ai: !assigneeName }">
{{ ticketInitials(assigneeName) }}
</span>
<div class="assignee-info">
<div class="assignee-name">{{ assigneeName || 'ChatterMate AI' }}</div>
<div class="assignee-sub">{{ assigneeName ? '人工坐席' : '未分配——由 AI 处理' }}</div>
</div>
<button v-if="canManage" class="change-btn" @click="isPickingAssignee = !isPickingAssignee">
更改
</button>
</div>
<div v-if="isPickingAssignee" class="assignee-picker">
<button class="picker-option" @click="pickAssignee(null)">ChatterMate AI(取消分配)</button>
<button
v-for="user in users"
:key="user.id"
class="picker-option"
@click="pickAssignee(user.id)"
>
{{ user.full_name || user.email }}
</button>
</div>
</div>
<div class="panel-card">
<div class="card-label-row">
<div class="card-label">客户</div>
<button
v-if="canManage && !isEditingCustomer"
class="change-btn"
@click="startCustomerEdit"
>
{{ ticket.customer ? '编辑' : '添加' }}
</button>
</div>
<template v-if="isEditingCustomer">
<input
v-model="customerEmailDraft"
type="email"
class="customer-input"
placeholder="customer@company.com"
maxlength="320"
/>
<input
v-model="customerNameDraft"
class="customer-input"
placeholder="姓名(选填)"
maxlength="200"
/>
<div class="customer-edit-actions">
<button class="customer-cancel" @click="isEditingCustomer = false">取消</button>
<button
class="customer-save"
:disabled="!customerEmailDraft.trim()"
@click="saveCustomer"
>
保存
</button>
</div>
</template>
<div v-else-if="ticket.customer" class="customer-row">
<span class="customer-avatar">
{{ (ticket.customer.full_name || ticket.customer.email || '?')[0]?.toUpperCase() }}
</span>
<div>
<div class="assignee-name">{{ ticket.customer.full_name || '客户' }}</div>
<div class="assignee-sub">{{ ticket.customer.email }}</div>
</div>
</div>
<div v-else class="no-customer">
未关联客户——此工单无法发送通知。
</div>
</div>
<div v-if="linkedSessionIds.length" class="panel-card">
<div class="card-label">关联会话</div>
<button class="open-conversation" @click="openConversation">打开会话 →</button>
</div>
<div class="panel-card">
<div class="card-label">SLA</div>
<div class="sla-row">
<span class="sla-label">首次响应</span>
<span class="sla-value" :style="{ color: firstResponseMet ? 'var(--c-positive)' : 'var(--muted2)' }">
<template v-if="firstResponseMet">
<font-awesome-icon :icon="['fas', 'check']" /> 已达成
</template>
<template v-else>等待中</template>
</span>
</div>
<div class="sla-row">
<span class="sla-label">解决时间</span>
<span class="sla-value mono" :style="{ color: sla.color }">{{ sla.label }}</span>
</div>
</div>
<div class="panel-card">
<div class="card-label">CSAT</div>
<div class="csat-row">
<span v-if="csat.state === 'scored'" class="csat-stars" :style="{ color: csat.color }">
<font-awesome-icon
v-for="star in 5"
:key="star"
:icon="[star <= csat.score ? 'fas' : 'far', 'star']"
/>
</span>
<span class="sla-value" :style="{ color: csat.color }">{{ csat.label }}</span>
</div>
<p class="csat-note">
<template v-if="csat.state === 'scored'">客户已在关联会话中评分。</template>
<template v-else-if="csat.state === 'pending'">关闭时已邀请评价——等待客户评分。</template>
<template v-else-if="linkedSessionIds.length">工单关闭时会自动邀请评价。</template>
<template v-else>没有可评价的关联会话,因此此工单不收集 CSAT。</template>
</p>
</div>
<div v-if="ticket.intent || ticket.ai_summary" class="panel-card">
<div class="card-label">AI 分诊</div>
<div v-if="ticket.intent" class="triage-row">
<span class="sla-label">意图</span>
<span class="sla-value mono">{{ ticket.intent }}</span>
</div>
<div v-if="ticket.triage_confidence != null" class="triage-row">
<span class="sla-label">置信度</span>
<span class="sla-value mono">{{ ticket.triage_confidence.toFixed(2) }}</span>
</div>
<p v-if="ticket.ai_summary" class="triage-summary">{{ ticket.ai_summary }}</p>
</div>
<div v-if="ticket.external_ref_id" class="panel-card">
<div class="card-label">外部引用</div>
<a
:href="ticket.external_ref_url || '#'"
target="_blank"
rel="noopener"
class="external-ref"
>
{{ ticket.external_ref_type }} · {{ ticket.external_ref_id }}
</a>
</div>
</aside>
</template>
<style scoped>
.side-panel {
display: flex;
flex-direction: column;
gap: 14px;
}
.panel-card {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 13px;
padding: 15px;
}
.card-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--faint);
margin-bottom: 11px;
}
.assignee-row,
.customer-row {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--c-coral);
color: var(--on-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: var(--font-weight-bold);
flex-shrink: 0;
}
.avatar.ai {
border-radius: 9px;
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.no-customer {
font-size: 12px;
color: var(--faint);
line-height: 1.5;
}
.card-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.customer-input {
width: 100%;
padding: 8px 11px;
background: var(--bg2);
border: 1px solid var(--o10);
border-radius: 9px;
color: var(--text);
font-size: 12.5px;
outline: none;
margin-bottom: 8px;
}
.customer-edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.customer-cancel {
padding: 6px 12px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
border-radius: 8px;
font-size: 12px;
cursor: pointer;
}
.customer-save {
padding: 6px 14px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: 8px;
font-size: 12px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.customer-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.customer-avatar {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--grad-purple-teal);
color: var(--on-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: var(--font-weight-bold);
font-family: var(--font-display);
flex-shrink: 0;
}
.assignee-info {
flex: 1;
min-width: 0;
}
.assignee-name {
font-size: 13px;
font-weight: var(--font-weight-semibold);
color: var(--text);
}
.assignee-sub {
font-size: 11px;
color: var(--faint);
overflow: hidden;
text-overflow: ellipsis;
}
.change-btn {
font-size: 11.5px;
color: var(--accent-ink);
background: var(--accent-bg-08);
border: none;
padding: 4px 9px;
border-radius: 7px;
cursor: pointer;
}
.assignee-picker {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 2px;
max-height: 200px;
overflow-y: auto;
}
.picker-option {
text-align: left;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text3);
font-size: 12.5px;
cursor: pointer;
}
.picker-option:hover {
background: var(--o05);
color: var(--text);
}
.open-conversation {
width: 100%;
padding: 9px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--text);
border-radius: 9px;
font-size: 12.5px;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
.open-conversation:hover {
background: var(--o08);
}
.sla-row,
.triage-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 9px;
}
.sla-row:last-child,
.triage-row:last-child {
margin-bottom: 0;
}
.sla-label {
font-size: 12.5px;
color: var(--text3);
}
.sla-value {
font-size: 11.5px;
font-weight: var(--font-weight-semibold);
}
.mono {
font-family: var(--font-mono);
font-size: 12px;
}
.csat-row {
display: flex;
align-items: center;
gap: 9px;
}
.csat-stars {
display: inline-flex;
gap: 3px;
font-size: 12px;
}
.csat-note {
margin: 9px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--faint);
}
.triage-summary {
margin: 10px 0 0;
font-size: 12.5px;
line-height: 1.55;
color: var(--text3);
}
.external-ref {
font-family: var(--font-mono);
font-size: 12px;
color: var(--c-teal);
text-decoration: none;
}
</style>
@@ -0,0 +1,44 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { TicketStatus } from '@/types/ticket'
import { statusMeta } from './ticketMeta'
const props = defineProps<{ status: TicketStatus }>()
const meta = computed(() => statusMeta(props.status))
</script>
<template>
<span class="ticket-status-badge" :style="{ '--chip-color': meta.color }">
{{ meta.label }}
</span>
</template>
<style scoped>
.ticket-status-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 11.5px;
font-weight: var(--font-weight-semibold);
color: var(--chip-color);
background: color-mix(in srgb, var(--chip-color) 14%, transparent);
white-space: nowrap;
}
</style>
@@ -0,0 +1,114 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Shared display metadata for ticket statuses / priorities / AI states.
Colors come from the design tokens so both themes work.
*/
import type { HypothesisStatus, TicketAiState, TicketPriority, TicketStatus } from '@/types/ticket'
export interface ChipMeta {
label: string
color: string
}
export const STATUS_META: Record<TicketStatus, ChipMeta> = {
open: { label: '待处理', color: 'var(--c-info)' },
triaging: { label: '待处理 · AI 分诊', color: 'var(--c-info)' },
investigating: { label: '待处理 · AI 处理中', color: 'var(--c-info)' },
awaiting_approval: { label: '待审批', color: 'var(--c-warn)' },
in_progress: { label: '处理中', color: 'var(--c-teal)' },
resolved_pending_confirmation: { label: '已解决 · 待确认', color: 'var(--c-positive)' },
resolved: { label: '已解决', color: 'var(--c-positive)' },
closed: { label: '已关闭', color: 'var(--c-neutral)' },
reopened: { label: '重新打开', color: 'var(--c-coral)' },
}
export const PRIORITY_META: Record<TicketPriority, ChipMeta> = {
urgent: { label: '紧急', color: 'var(--c-danger)' },
high: { label: '高', color: 'var(--c-coral)' },
medium: { label: '中', color: 'var(--c-warn)' },
low: { label: '低', color: 'var(--c-neutral)' },
}
export const AI_STATE_META: Record<TicketAiState, ChipMeta & { pulse: boolean }> = {
investigating: { label: 'AI 调查中', color: 'var(--c-info)', pulse: true },
awaiting: { label: '等待人工', color: 'var(--c-warn)', pulse: false },
human: { label: '人工处理', color: 'var(--c-neutral)', pulse: false },
resolved: { label: 'AI 已解决', color: 'var(--c-positive)', pulse: false },
}
// Human-selectable statuses for the detail dropdown, in lifecycle order.
// Triaging/investigating are AI-driven and not directly selectable.
export const SELECTABLE_STATUSES: TicketStatus[] = [
'open',
'in_progress',
'awaiting_approval',
'resolved_pending_confirmation',
'resolved',
'closed',
'reopened',
]
export const PRIORITIES: TicketPriority[] = ['urgent', 'high', 'medium', 'low']
export function statusMeta(status: TicketStatus): ChipMeta {
return STATUS_META[status] || { label: status, color: 'var(--c-neutral)' }
}
export function priorityMeta(priority: TicketPriority): ChipMeta {
return PRIORITY_META[priority] || { label: priority, color: 'var(--c-neutral)' }
}
export function aiStateMeta(state?: TicketAiState | null) {
return (state && AI_STATE_META[state]) || AI_STATE_META.human
}
export const HYPOTHESIS_STATUS_META: Record<HypothesisStatus, ChipMeta> = {
pending: { label: '待验证', color: 'var(--c-neutral)' },
testing: { label: '验证中', color: 'var(--c-info)' },
validated: { label: '已验证', color: 'var(--c-positive)' },
invalidated: { label: '已推翻', color: 'var(--c-danger)' },
inconclusive: { label: '无定论', color: 'var(--c-warn)' },
}
export function hypothesisMeta(status: HypothesisStatus): ChipMeta {
return HYPOTHESIS_STATUS_META[status] || { label: status, color: 'var(--c-neutral)' }
}
export function ticketInitials(name?: string | null): string {
if (!name) return 'AI'
return name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('')
}
export function formatSlaCountdown(slaDueAt?: string | null, resolvedAt?: string | null): ChipMeta {
if (resolvedAt || !slaDueAt) return { label: '—', color: 'var(--faint)' }
const remainingMs = new Date(slaDueAt).getTime() - Date.now()
if (remainingMs <= 0) return { label: '已超时', color: 'var(--c-danger)' }
const minutes = Math.floor(remainingMs / 60000)
const label =
minutes >= 60 * 24
? `${Math.floor(minutes / (60 * 24))}天 ${Math.floor((minutes % (60 * 24)) / 60)}小时`
: minutes >= 60
? `${Math.floor(minutes / 60)}小时 ${minutes % 60}分`
: `${minutes}分钟`
const color =
minutes < 10 ? 'var(--c-danger)' : minutes < 30 ? 'var(--c-warn)' : 'var(--muted2)'
return { label, color }
}
@@ -0,0 +1,668 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Node } from '@vue-flow/core'
const props = defineProps<{
node: Node
workflowId: string
}>()
const emit = defineEmits<{
(e: 'save', config: any): void
(e: 'cancel'): void
(e: 'delete', nodeId: string): void
}>()
// Form state
const formData = ref({
label: '',
description: '',
config: {} as Record<string, any>
})
// Initialize form data
onMounted(() => {
formData.value = {
label: props.node.data.label || '',
description: props.node.data.description || '',
config: { ...(props.node.data.config || {}) }
}
})
// Field configuration type
interface FieldConfig {
key: string
label: string
type: string
required?: boolean
placeholder?: string
options?: string[]
min?: number
max?: number
step?: number
default?: any
arrayType?: string
}
// Node type configurations
const nodeConfigs: Record<string, { fields: FieldConfig[] }> = {
message: {
fields: [
{ key: 'message', label: '消息内容', type: 'textarea', required: true }
]
},
llm: {
fields: [
{ key: 'model', label: 'AI 模型', type: 'select', options: ['gpt-4', 'gpt-3.5-turbo', 'claude-3'], required: true },
{ key: 'systemPrompt', label: '系统提示词', type: 'textarea', required: true },
{ key: 'temperature', label: '温度', type: 'number', min: 0, max: 2, step: 0.1, default: 0.7 },
{ key: 'maxTokens', label: '最大 Token 数', type: 'number', min: 1, max: 4000, default: 1000 }
]
},
condition: {
fields: [
{ key: 'condition', label: '条件表达式', type: 'textarea', required: true },
{ key: 'conditionType', label: '条件类型', type: 'select', options: ['javascript', 'simple'], required: true }
]
},
form: {
fields: [
{ key: 'fields', label: '表单字段', type: 'array', arrayType: 'object', required: true }
]
},
action: {
fields: [
{ key: 'actionType', label: '动作类型', type: 'select', options: ['webhook', 'email', 'api'], required: true },
{ key: 'url', label: 'URL', type: 'text', required: true },
{ key: 'method', label: 'HTTP 方法', type: 'select', options: ['GET', 'POST', 'PUT', 'DELETE'], default: 'POST' },
{ key: 'headers', label: '请求头', type: 'object' },
{ key: 'body', label: '请求体', type: 'textarea' }
]
},
humanTransfer: {
fields: [
{ key: 'department', label: '部门', type: 'select', options: ['general', 'sales', 'support', 'technical'], required: true },
{ key: 'message', label: '转接消息', type: 'textarea' },
{ key: 'priority', label: '优先级', type: 'select', options: ['low', 'medium', 'high'], default: 'medium' }
]
},
wait: {
fields: [
{ key: 'duration', label: '时长', type: 'number', min: 1, required: true },
{ key: 'unit', label: '时间单位', type: 'select', options: ['seconds', 'minutes', 'hours', 'days'], default: 'seconds' },
{ key: 'waitType', label: '等待类型', type: 'select', options: ['fixed', 'user_input'], default: 'fixed' }
]
},
end: {
fields: [
{ key: 'endType', label: '结束类型', type: 'select', options: ['success', 'failure', 'timeout'], default: 'success' },
{ key: 'finalMessage', label: '最终消息', type: 'textarea' }
]
}
}
// Get configuration for current node type
const currentConfig = computed(() => {
return nodeConfigs[props.node.type as keyof typeof nodeConfigs] || { fields: [] }
})
// Handle form submission
const handleSave = () => {
emit('save', formData.value)
}
// Handle cancel
const handleCancel = () => {
emit('cancel')
}
// Handle delete
const handleDelete = () => {
if (confirm('确定要删除此节点吗?')) {
emit('delete', props.node.id)
}
}
// Handle array field changes
const addArrayItem = (fieldKey: string) => {
if (!formData.value.config[fieldKey]) {
formData.value.config[fieldKey] = []
}
formData.value.config[fieldKey].push({})
}
const removeArrayItem = (fieldKey: string, index: number) => {
if (formData.value.config[fieldKey]) {
formData.value.config[fieldKey].splice(index, 1)
}
}
// Get node type display name
const getNodeTypeName = (type: string | undefined) => {
if (!type) return '未知'
const names = {
message: '消息',
llm: 'LLM',
condition: '条件',
form: '表单',
action: '动作',
humanTransfer: '转人工',
wait: '等待',
end: '结束'
}
return names[type as keyof typeof names] || type
}
// Handle object field changes
const handleObjectChange = (fieldKey: string, value: string) => {
try {
formData.value.config[fieldKey] = JSON.parse(value)
} catch (e) {
// Keep as string if not valid JSON
formData.value.config[fieldKey] = value
}
}
</script>
<template>
<div class="node-config-overlay" @click="handleCancel">
<div class="node-config-panel" @click.stop>
<div class="panel-header">
<div class="header-left">
<h3>配置{{ getNodeTypeName(node.type) }}节点</h3>
<span class="node-id">ID:{{ node.id }}</span>
</div>
<button class="close-button" @click="handleCancel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="panel-content">
<form @submit.prevent="handleSave">
<!-- Basic Information -->
<div class="form-section">
<h4>基本信息</h4>
<div class="form-group">
<label for="node-label">节点名称 *</label>
<input
id="node-label"
v-model="formData.label"
type="text"
class="form-input"
placeholder="输入节点名称"
required
/>
</div>
<div class="form-group">
<label for="node-description">描述</label>
<textarea
id="node-description"
v-model="formData.description"
class="form-textarea"
placeholder="输入节点描述(可选)"
rows="3"
></textarea>
</div>
</div>
<!-- Node-specific Configuration -->
<div v-if="currentConfig.fields.length > 0" class="form-section">
<h4>{{ getNodeTypeName(node.type) }}配置</h4>
<div
v-for="field in currentConfig.fields"
:key="field.key"
class="form-group"
>
<label :for="`config-${field.key}`">
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<!-- Text Input -->
<input
v-if="field.type === 'text'"
:id="`config-${field.key}`"
v-model="formData.config[field.key]"
type="text"
class="form-input"
:placeholder="field.placeholder || `输入${field.label?.toLowerCase() || '值'}`"
:required="field.required"
/>
<!-- Number Input -->
<input
v-else-if="field.type === 'number'"
:id="`config-${field.key}`"
v-model.number="formData.config[field.key]"
type="number"
class="form-input"
:min="field.min"
:max="field.max"
:step="field.step"
:placeholder="field.default?.toString() || ''"
:required="field.required"
/>
<!-- Textarea -->
<textarea
v-else-if="field.type === 'textarea'"
:id="`config-${field.key}`"
v-model="formData.config[field.key]"
class="form-textarea"
:placeholder="field.placeholder || `输入${field.label?.toLowerCase() || '值'}`"
rows="4"
:required="field.required"
></textarea>
<!-- Select -->
<select
v-else-if="field.type === 'select'"
:id="`config-${field.key}`"
v-model="formData.config[field.key]"
class="form-select"
:required="field.required"
>
<option value="">选择{{ field.label.toLowerCase() }}</option>
<option
v-for="option in field.options"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Checkbox -->
<label
v-else-if="field.type === 'checkbox'"
class="checkbox-label"
>
<input
:id="`config-${field.key}`"
v-model="formData.config[field.key]"
type="checkbox"
class="form-checkbox"
/>
<span class="checkbox-text">{{ field.label }}</span>
</label>
<!-- Object (JSON) -->
<textarea
v-else-if="field.type === 'object'"
:id="`config-${field.key}`"
:value="JSON.stringify(formData.config[field.key] || {}, null, 2)"
@input="handleObjectChange(field.key, ($event.target as HTMLTextAreaElement).value)"
class="form-textarea"
placeholder="输入 JSON 对象"
rows="4"
></textarea>
<!-- Array -->
<div v-else-if="field.type === 'array'" class="array-field">
<div
v-for="(item, index) in formData.config[field.key] || []"
:key="index"
class="array-item"
>
<input
v-if="field.arrayType === 'string'"
v-model="formData.config[field.key][index]"
type="text"
class="form-input"
:placeholder="`${field.label} ${index + 1}`"
/>
<div v-else-if="field.arrayType === 'object'" class="object-item">
<input
v-model="formData.config[field.key][index].name"
type="text"
class="form-input"
placeholder="字段名"
/>
<select
v-model="formData.config[field.key][index].type"
class="form-select"
>
<option value="text">文本</option>
<option value="email">邮箱</option>
<option value="number">数字</option>
<option value="select">下拉选择</option>
<option value="checkbox">复选框</option>
</select>
</div>
<button
type="button"
class="remove-item-btn"
@click="removeArrayItem(field.key, index)"
>
×
</button>
</div>
<button
type="button"
class="add-item-btn"
@click="addArrayItem(field.key)"
>
添加{{ field.label }}
</button>
</div>
</div>
</div>
</form>
</div>
<div class="panel-footer">
<button type="button" class="btn btn-danger" @click="handleDelete">
删除节点
</button>
<div class="footer-actions">
<button type="button" class="btn btn-secondary" @click="handleCancel">
取消
</button>
<button type="submit" class="btn btn-primary" @click="handleSave">
保存修改
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.node-config-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
}
.node-config-panel {
background: var(--background-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-lg);
border-bottom: 1px solid var(--border-color);
background: var(--background-soft);
}
.header-left h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: var(--text-color);
}
.node-id {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 2px;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: var(--background-muted);
color: var(--text-color);
}
.close-button svg {
width: 16px;
height: 16px;
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: var(--space-lg);
}
.form-section {
margin-bottom: var(--space-xl);
}
.form-section h4 {
margin: 0 0 var(--space-md) 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--space-sm);
}
.form-group {
margin-bottom: var(--space-md);
}
.form-group label {
display: block;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-color);
margin-bottom: var(--space-xs);
}
.required {
color: var(--error-color);
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: var(--space-sm);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: var(--text-sm);
transition: border-color 0.2s ease;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-textarea {
resize: vertical;
min-height: 80px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
font-weight: normal;
}
.form-checkbox {
width: auto;
margin: 0;
}
.checkbox-text {
font-size: var(--text-sm);
color: var(--text-color);
}
.array-field {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-sm);
background: var(--background-soft);
}
.array-item {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
align-items: center;
}
.object-item {
display: flex;
gap: var(--space-sm);
flex: 1;
}
.remove-item-btn {
background: var(--error-color);
color: white;
border: none;
border-radius: var(--radius-sm);
width: 28px;
height: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
line-height: 1;
flex-shrink: 0;
}
.add-item-btn {
background: var(--accent-solid);
color: var(--on-accent-solid);
border: none;
border-radius: var(--radius-md);
padding: var(--space-sm);
cursor: pointer;
font-size: var(--text-sm);
transition: background-color 0.2s ease;
}
.add-item-btn:hover {
background: var(--primary-dark);
}
.panel-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-lg);
border-top: 1px solid var(--border-color);
background: var(--background-soft);
}
.footer-actions {
display: flex;
gap: var(--space-sm);
}
.btn {
padding: var(--space-sm) var(--space-md);
border: none;
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-size: var(--text-sm);
}
.btn-primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-secondary {
background: var(--background-muted);
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--background-alt);
color: var(--text-color);
}
.btn-danger {
background: var(--error-color);
color: white;
}
.btn-danger:hover {
background: #DC2626;
}
/* Responsive */
@media (max-width: 768px) {
.node-config-panel {
width: 95%;
max-height: 90vh;
}
.panel-footer {
flex-direction: column;
gap: var(--space-md);
}
.footer-actions {
width: 100%;
justify-content: stretch;
}
.btn {
flex: 1;
}
}
</style>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,145 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
defineProps<{
data: Node['data']
}>()
</script>
<template>
<div class="action-node">
<Handle
id="input"
type="target"
:position="Position.Left"
class="node-handle"
/>
<div class="node-content">
<div class="node-header">
<div class="node-icon">⚡</div>
<div class="node-title">{{ data.label || '动作' }}</div>
</div>
<div v-if="data.description" class="node-description">
{{ data.description }}
</div>
<div v-if="data.config?.actionType" class="node-preview">
<div class="preview-label">动作类型:</div>
<div class="preview-content">{{ data.config.actionType }}</div>
</div>
<div v-if="data.config?.url" class="node-preview">
<div class="preview-label">URL:</div>
<div class="preview-content">
{{ data.config.url.substring(0, 80) }}{{ data.config.url.length > 80 ? '...' : '' }}
</div>
</div>
</div>
<Handle
id="output"
type="source"
:position="Position.Right"
class="node-handle"
/>
</div>
</template>
<style scoped>
.action-node {
position: relative;
background: var(--background-color);
border: 2px solid #EF4444;
border-radius: var(--radius-lg);
padding: var(--space-md);
min-width: 200px;
max-width: 300px;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
.action-node:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.node-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.node-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.node-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-sm);
}
.node-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.3;
}
.node-preview {
background: var(--background-soft);
border-radius: var(--radius-sm);
padding: var(--space-sm);
border: 1px solid var(--border-color);
}
.preview-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 2px;
}
.preview-content {
font-size: 0.7rem;
color: var(--text-color);
line-height: 1.3;
}
.node-handle {
background: #EF4444;
border: 2px solid var(--background-color);
width: 12px;
height: 12px;
}
.node-handle:hover {
background: #DC2626;
transform: scale(1.2);
}
</style>
@@ -0,0 +1,155 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
interface ActionNodeData {
action_type: string
action_url: string
}
const props = defineProps<{
modelValue: ActionNodeData
validationErrors: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: ActionNodeData): void
(e: 'validate-field', field: string): void
}>()
const formData = computed({
get: () => props.modelValue,
set: (value: ActionNodeData) => emit('update:model-value', value)
})
// Update form data
const updateFormData = (field: keyof ActionNodeData, value: any) => {
formData.value = {
...formData.value,
[field]: value
}
emit('validate-field', field)
}
</script>
<template>
<div class="action-node-config">
<div class="form-group">
<label for="action-type">动作类型 *</label>
<select
id="action-type"
:value="formData.action_type"
@change="updateFormData('action_type', ($event.target as HTMLSelectElement).value)"
@blur="$emit('validate-field', 'action_type')"
class="form-select"
:class="{ 'error': validationErrors.action_type }"
required
>
<option value="">选择动作类型</option>
<option value="webhook">Webhook</option>
<option value="email">发送邮件</option>
<option value="api">API 调用</option>
</select>
<div v-if="validationErrors.action_type" class="error-message">
{{ validationErrors.action_type }}
</div>
</div>
<div class="form-group">
<label for="action-url">URL *</label>
<input
id="action-url"
:value="formData.action_url"
@input="updateFormData('action_url', ($event.target as HTMLInputElement).value)"
@blur="$emit('validate-field', 'action_url')"
type="url"
class="form-input"
:class="{ 'error': validationErrors.action_url }"
placeholder="https://example.com/webhook"
required
/>
<div v-if="validationErrors.action_url" class="error-message">
{{ validationErrors.action_url }}
</div>
</div>
</div>
</template>
<style scoped>
.action-node-config {
width: 100%;
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-input,
.form-select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-input.error,
.form-select.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-input.error:focus,
.form-select.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
</style>
@@ -0,0 +1,177 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
defineProps<{
data: Node['data']
}>()
</script>
<template>
<div class="condition-node">
<Handle
id="input"
type="target"
:position="Position.Left"
class="node-handle"
/>
<div class="node-content">
<div class="node-header">
<div class="node-icon">🔀</div>
<div class="node-title">{{ data.label || '条件' }}</div>
</div>
<div v-if="data.description" class="node-description">
{{ data.description }}
</div>
<div v-if="data.config?.condition" class="node-preview">
<div class="preview-label">条件:</div>
<div class="preview-content">
{{ data.config.condition.substring(0, 80) }}{{ data.config.condition.length > 80 ? '...' : '' }}
</div>
</div>
</div>
<Handle
id="true"
type="source"
:position="Position.Right"
:style="{ top: '30%' }"
class="node-handle true-handle"
/>
<div class="handle-label true-label">是</div>
<Handle
id="false"
type="source"
:position="Position.Right"
:style="{ top: '70%' }"
class="node-handle false-handle"
/>
<div class="handle-label false-label">否</div>
</div>
</template>
<style scoped>
.condition-node {
position: relative;
background: var(--background-color);
border: 2px solid #F59E0B;
border-radius: var(--radius-lg);
padding: var(--space-md);
min-width: 200px;
max-width: 300px;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
.condition-node:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.node-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.node-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.node-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-sm);
}
.node-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.3;
}
.node-preview {
background: var(--background-soft);
border-radius: var(--radius-sm);
padding: var(--space-sm);
border: 1px solid var(--border-color);
}
.preview-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 2px;
}
.preview-content {
font-size: 0.7rem;
color: var(--text-color);
line-height: 1.3;
}
.node-handle {
background: #F59E0B;
border: 2px solid var(--background-color);
width: 12px;
height: 12px;
}
.node-handle.true-handle {
background: #10B981;
}
.node-handle.false-handle {
background: #EF4444;
}
.node-handle:hover {
transform: scale(1.2);
}
.handle-label {
position: absolute;
right: -35px;
font-size: 0.6rem;
color: var(--text-muted);
font-weight: 500;
pointer-events: none;
}
.true-label {
top: 25%;
color: #10B981;
}
.false-label {
top: 65%;
color: #EF4444;
}
</style>
@@ -0,0 +1,592 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
interface ConditionRule {
id: string
variable: string
operator: string
value: string
}
interface ConditionGroup {
id: string
rules: ConditionRule[]
logicalOperator: 'AND' | 'OR'
}
interface ConditionNodeData {
condition_expression: string
condition_groups?: ConditionGroup[]
}
interface Variable {
nodeId: string
nodeName: string
fieldName: string
fieldType: string
fieldLabel: string
}
const props = defineProps<{
modelValue: ConditionNodeData
validationErrors: Record<string, string>
availableVariables?: Variable[]
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: ConditionNodeData): void
(e: 'validate-field', field: string): void
}>()
// Available operators
const operators = [
{ value: '===', label: '等于' },
{ value: '!==', label: '不等于' },
{ value: '>', label: '大于' },
{ value: '<', label: '小于' },
{ value: '>=', label: '大于或等于' },
{ value: '<=', label: '小于或等于' },
{ value: 'includes', label: '包含' },
{ value: 'startsWith', label: '开头是' },
{ value: 'endsWith', label: '结尾是' }
]
// Initialize condition groups from existing data or create default
const conditionGroups = ref<ConditionGroup[]>(
props.modelValue.condition_groups || [
{
id: generateId(),
rules: [
{
id: generateId(),
variable: '',
operator: '===',
value: ''
}
],
logicalOperator: 'AND'
}
]
)
// Generate unique ID
function generateId(): string {
return Math.random().toString(36).substr(2, 9)
}
// Factory for default condition group structure
function createDefaultConditionGroups(): ConditionGroup[] {
return [
{
id: generateId(),
rules: [
{ id: generateId(), variable: '', operator: '===', value: '' }
],
logicalOperator: 'AND'
}
]
}
// Keep local state in sync when switching nodes or external updates occur
watch(
() => props.modelValue,
(newValue) => {
if (newValue && Array.isArray(newValue.condition_groups)) {
conditionGroups.value = newValue.condition_groups.length > 0
? newValue.condition_groups
: createDefaultConditionGroups()
} else {
conditionGroups.value = createDefaultConditionGroups()
}
},
{ deep: true, immediate: true }
)
// Update condition groups and generate expression
const updateConditions = () => {
const expression = generateConditionExpression()
const updatedData: ConditionNodeData = {
condition_expression: expression,
condition_groups: conditionGroups.value
}
emit('update:model-value', updatedData)
emit('validate-field', 'condition_expression')
emit('validate-field', 'condition_groups')
}
// Generate JavaScript expression from condition groups
const generateConditionExpression = (): string => {
if (conditionGroups.value.length === 0) return ''
const groupExpressions = conditionGroups.value.map(group => {
if (group.rules.length === 0) return ''
const ruleExpressions = group.rules
.filter(rule => rule.variable && rule.operator && rule.value)
.map(rule => {
const variable = `{{${rule.variable}}}`
const value = isNaN(Number(rule.value)) ? `"${rule.value}"` : rule.value
if (rule.operator === 'includes') {
return `${variable}.includes(${value})`
} else if (rule.operator === 'startsWith') {
return `${variable}.startsWith(${value})`
} else if (rule.operator === 'endsWith') {
return `${variable}.endsWith(${value})`
} else {
return `${variable} ${rule.operator} ${value}`
}
})
if (ruleExpressions.length === 0) return ''
if (ruleExpressions.length === 1) return ruleExpressions[0]
return `(${ruleExpressions.join(` ${group.logicalOperator} `)})`
}).filter(expr => expr)
if (groupExpressions.length === 0) return ''
if (groupExpressions.length === 1) return groupExpressions[0]
return groupExpressions.join(' OR ')
}
// Add new rule to a group
const addRule = (groupId: string) => {
const group = conditionGroups.value.find(g => g.id === groupId)
if (group) {
group.rules.push({
id: generateId(),
variable: '',
operator: '===',
value: ''
})
updateConditions()
}
}
// Remove rule from a group
const removeRule = (groupId: string, ruleId: string) => {
const group = conditionGroups.value.find(g => g.id === groupId)
if (group && group.rules.length > 1) {
group.rules = group.rules.filter(r => r.id !== ruleId)
updateConditions()
}
}
// Add new condition group
const addGroup = () => {
conditionGroups.value.push({
id: generateId(),
rules: [
{
id: generateId(),
variable: '',
operator: '===',
value: ''
}
],
logicalOperator: 'AND'
})
updateConditions()
}
// Remove condition group
const removeGroup = (groupId: string) => {
if (conditionGroups.value.length > 1) {
conditionGroups.value = conditionGroups.value.filter(g => g.id !== groupId)
updateConditions()
}
}
// Update rule
const updateRule = (groupId: string, ruleId: string, field: keyof ConditionRule, value: string) => {
const group = conditionGroups.value.find(g => g.id === groupId)
if (group) {
const rule = group.rules.find(r => r.id === ruleId)
if (rule) {
rule[field] = value
updateConditions()
}
}
}
// Update group logical operator
const updateGroupOperator = (groupId: string, operator: 'AND' | 'OR') => {
const group = conditionGroups.value.find(g => g.id === groupId)
if (group) {
group.logicalOperator = operator
updateConditions()
}
}
</script>
<template>
<div class="condition-node-config">
<!-- Condition Builder -->
<div class="form-group">
<label>条件规则 *</label>
<div v-if="!availableVariables || availableVariables.length === 0" class="no-variables">
<p>暂无可用的变量。请在此条件节点之前添加表单或用户输入节点,以便在条件中使用它们的值。</p>
</div>
<div v-else class="condition-builder">
<div
v-for="(group, groupIndex) in conditionGroups"
:key="group.id"
class="condition-group"
>
<!-- Group Header -->
<div v-if="groupIndex > 0" class="group-separator">
<span class="separator-text">或</span>
</div>
<div class="group-content">
<!-- Rules within the group -->
<div
v-for="(rule, ruleIndex) in group.rules"
:key="rule.id"
class="condition-rule"
>
<!-- Rule logical operator (AND/OR) -->
<div v-if="ruleIndex > 0" class="rule-operator">
<select
:value="group.logicalOperator"
@change="updateGroupOperator(group.id, ($event.target as HTMLSelectElement).value as 'AND' | 'OR')"
class="operator-select"
>
<option value="AND">AND</option>
<option value="OR">OR</option>
</select>
</div>
<!-- Rule configuration -->
<div class="rule-config">
<!-- Variable selection -->
<select
:value="rule.variable"
@change="updateRule(group.id, rule.id, 'variable', ($event.target as HTMLSelectElement).value)"
class="variable-select"
>
<option value="">选择变量</option>
<option
v-for="variable in availableVariables"
:key="variable.fieldName"
:value="variable.fieldName"
>
{{ variable.fieldLabel }}
</option>
</select>
<!-- Operator selection -->
<select
:value="rule.operator"
@change="updateRule(group.id, rule.id, 'operator', ($event.target as HTMLSelectElement).value)"
class="operator-select"
>
<option
v-for="op in operators"
:key="op.value"
:value="op.value"
>
{{ op.label }}
</option>
</select>
<!-- Value input -->
<input
type="text"
:value="rule.value"
@input="updateRule(group.id, rule.id, 'value', ($event.target as HTMLInputElement).value)"
placeholder="输入值"
class="value-input"
/>
<!-- Remove rule button -->
<button
v-if="group.rules.length > 1"
type="button"
@click="removeRule(group.id, rule.id)"
class="remove-btn rule-remove"
title="删除规则"
>
×
</button>
</div>
</div>
<!-- Add rule button -->
<button
type="button"
@click="addRule(group.id)"
class="add-btn add-rule"
>
+ 添加规则
</button>
</div>
<!-- Remove group button -->
<button
v-if="conditionGroups.length > 1"
type="button"
@click="removeGroup(group.id)"
class="remove-btn group-remove"
title="删除分组"
>
×
</button>
</div>
<!-- Add group button -->
<button
type="button"
@click="addGroup"
class="add-btn add-group"
>
+ 添加分组(或)
</button>
</div>
<!-- Generated expression preview -->
<div v-if="generateConditionExpression()" class="expression-preview">
<label>生成的表达式:</label>
<code class="expression-code">{{ generateConditionExpression() }}</code>
</div>
<div v-if="validationErrors.condition_expression" class="error-message">
{{ validationErrors.condition_expression }}
</div>
</div>
</div>
</template>
<style scoped>
.condition-node-config {
width: 100%;
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 8px;
}
.no-variables {
font-size: 0.8rem;
color: var(--text-muted);
font-style: italic;
margin: 8px 0;
padding: 12px;
background: var(--background-soft);
border-radius: var(--radius-md);
border: 1px dashed var(--border-color);
}
.condition-builder {
display: flex;
flex-direction: column;
gap: 16px;
}
.condition-group {
position: relative;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: 12px;
background: var(--background-soft);
}
.group-separator {
display: flex;
justify-content: center;
margin: 8px 0;
}
.separator-text {
background: var(--background-color);
padding: 4px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
}
.group-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.condition-rule {
display: flex;
flex-direction: column;
gap: 8px;
}
.rule-operator {
display: flex;
justify-content: center;
}
.rule-config {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.variable-select,
.operator-select {
flex: 1;
min-width: 120px;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--background-color);
color: var(--text-color);
font-size: 0.8rem;
}
.value-input {
flex: 1;
min-width: 100px;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--background-color);
color: var(--text-color);
font-size: 0.8rem;
}
.variable-select:focus,
.operator-select:focus,
.value-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.add-btn {
padding: 6px 12px;
border: 1px dashed var(--border-color);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.add-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
background: rgba(201, 242, 78, 0.05);
}
.add-rule {
align-self: flex-start;
margin-top: 4px;
}
.add-group {
align-self: center;
}
.remove-btn {
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: var(--error-color);
color: white;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
}
.remove-btn:hover {
background: var(--error-dark, #dc2626);
transform: scale(1.1);
}
.group-remove {
position: absolute;
top: -8px;
right: -8px;
}
.rule-remove {
margin-left: 4px;
}
.expression-preview {
margin-top: 12px;
padding: 8px;
background: var(--background-alt);
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
}
.expression-preview label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 4px;
}
.expression-code {
display: block;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.75rem;
color: var(--text-color);
background: var(--background-color);
padding: 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-color);
word-break: break-all;
white-space: pre-wrap;
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
</style>
@@ -0,0 +1,140 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
defineProps<{
data: Node['data']
}>()
</script>
<template>
<div class="end-node">
<Handle
id="input"
type="target"
:position="Position.Left"
class="node-handle"
/>
<div class="node-content">
<div class="node-header">
<div class="node-icon">🏁</div>
<div class="node-title">{{ data.label || '结束' }}</div>
</div>
<div v-if="data.description" class="node-description">
{{ data.description }}
</div>
<div v-if="data.config?.endType" class="node-preview">
<div class="preview-label">结束类型:</div>
<div class="preview-content">{{ data.config.endType }}</div>
</div>
<div v-if="data.config?.finalMessage" class="node-preview">
<div class="preview-label">最终消息:</div>
<div class="preview-content">
{{ data.config.finalMessage.substring(0, 80) }}{{ data.config.finalMessage.length > 80 ? '...' : '' }}
</div>
</div>
</div>
<!-- End node doesn't have output handle -->
</div>
</template>
<style scoped>
.end-node {
position: relative;
background: var(--background-color);
border: 2px solid #DC2626;
border-radius: var(--radius-lg);
padding: var(--space-md);
min-width: 200px;
max-width: 300px;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
.end-node:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.node-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.node-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.node-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-sm);
}
.node-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.3;
}
.node-preview {
background: var(--background-soft);
border-radius: var(--radius-sm);
padding: var(--space-sm);
border: 1px solid var(--border-color);
}
.preview-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 2px;
}
.preview-content {
font-size: 0.7rem;
color: var(--text-color);
line-height: 1.3;
}
.node-handle {
background: #DC2626;
border: 2px solid var(--background-color);
width: 12px;
height: 12px;
}
.node-handle:hover {
background: #B91C1C;
transform: scale(1.2);
}
</style>
@@ -0,0 +1,123 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
interface EndNodeData {
final_message: string
}
const props = defineProps<{
modelValue: EndNodeData
validationErrors: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: EndNodeData): void
(e: 'validate-field', field: string): void
}>()
const formData = computed({
get: () => props.modelValue,
set: (value: EndNodeData) => emit('update:model-value', value)
})
// Update form data
const updateFormData = (field: keyof EndNodeData, value: any) => {
formData.value = {
...formData.value,
[field]: value
}
}
</script>
<template>
<div class="end-node-config">
<div class="form-group">
<label for="end-message">最终消息</label>
<textarea
id="end-message"
:value="formData.final_message"
@input="updateFormData('final_message', ($event.target as HTMLTextAreaElement).value)"
class="form-textarea"
placeholder="结束会话时显示的最终消息"
rows="3"
></textarea>
</div>
</div>
</template>
<style scoped>
.end-node-config {
width: 100%;
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
resize: vertical;
min-height: 70px;
}
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-textarea.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-textarea.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
</style>
@@ -0,0 +1,163 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
defineProps<{
data: Node['data']
}>()
</script>
<template>
<div class="form-node">
<Handle
id="input"
type="target"
:position="Position.Left"
class="node-handle"
/>
<div class="node-content">
<div class="node-header">
<div class="node-icon">📝</div>
<div class="node-title">{{ data.label || '表单' }}</div>
</div>
<div v-if="data.description" class="node-description">
{{ data.description }}
</div>
<div v-if="data.config?.fields?.length" class="node-preview">
<div class="preview-label">字段({{ data.config.fields.length }}):</div>
<div class="field-list">
<div
v-for="field in data.config.fields.slice(0, 3)"
:key="field.name"
class="field-item"
>
{{ field.name }} ({{ field.type }})
</div>
<div v-if="data.config.fields.length > 3" class="field-more">
+{{ data.config.fields.length - 3 }} 项
</div>
</div>
</div>
</div>
<Handle
id="output"
type="source"
:position="Position.Right"
class="node-handle"
/>
</div>
</template>
<style scoped>
.form-node {
position: relative;
background: var(--background-color);
border: 2px solid #10B981;
border-radius: var(--radius-lg);
padding: var(--space-md);
min-width: 200px;
max-width: 300px;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
.form-node:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.node-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.node-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.node-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-sm);
}
.node-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.3;
}
.node-preview {
background: var(--background-soft);
border-radius: var(--radius-sm);
padding: var(--space-sm);
border: 1px solid var(--border-color);
}
.preview-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 4px;
}
.field-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.field-item {
font-size: 0.65rem;
color: var(--text-color);
padding: 2px 4px;
background: rgba(16, 185, 129, 0.1);
border-radius: 3px;
}
.field-more {
font-size: 0.65rem;
color: var(--text-muted);
font-style: italic;
}
.node-handle {
background: #10B981;
border: 2px solid var(--background-color);
width: 12px;
height: 12px;
}
.node-handle:hover {
background: #059669;
transform: scale(1.2);
}
</style>
@@ -0,0 +1,604 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
// Define form field interface
interface FormField {
name: string
label: string
type: string
required: boolean
placeholder: string
options: string | string[] // Can be string (for textarea display) or array (when saved)
minLength: number
maxLength: number
}
interface FormNodeData {
form_title: string
form_description: string
submit_button_text: string
form_full_screen: boolean
form_fields: FormField[]
}
const props = defineProps<{
modelValue: FormNodeData
validationErrors: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: FormNodeData): void
(e: 'validate-field', field: string): void
}>()
const formData = computed({
get: () => props.modelValue,
set: (value: FormNodeData) => emit('update:model-value', value)
})
// Get field type icon
const getFieldTypeIcon = (type: string) => {
const icons = {
text: '📝',
email: '📧',
number: '🔢',
tel: '📞',
textarea: '📄',
select: '📋',
checkbox: '☑️',
radio: '🔘'
}
return icons[type as keyof typeof icons] || '📝'
}
// Add form field
const addFormField = () => {
const updatedFields = [...formData.value.form_fields, {
name: '',
label: '',
type: 'text',
required: false,
placeholder: '',
options: '',
minLength: 0,
maxLength: 255
}]
formData.value = {
...formData.value,
form_fields: updatedFields
}
// Trigger validation for form fields
emit('validate-field', 'form_fields')
}
// Remove form field
const removeFormField = (index: number) => {
const updatedFields = formData.value.form_fields.filter((_, i) => i !== index)
formData.value = {
...formData.value,
form_fields: updatedFields
}
// Trigger validation for form fields
emit('validate-field', 'form_fields')
}
// Update individual field
const updateField = (index: number, field: string, value: any) => {
const updatedFields = [...formData.value.form_fields]
updatedFields[index] = {
...updatedFields[index],
[field]: value
}
formData.value = {
...formData.value,
form_fields: updatedFields
}
emit('validate-field', 'form_fields')
}
// Update form data
const updateFormData = (field: keyof FormNodeData, value: any) => {
formData.value = {
...formData.value,
[field]: value
}
if (field !== 'form_fields') {
emit('validate-field', field)
}
}
</script>
<template>
<div class="form-node-config">
<div class="form-group">
<label for="form-title">表单标题</label>
<input
id="form-title"
:value="formData.form_title"
@input="updateFormData('form_title', ($event.target as HTMLInputElement).value)"
type="text"
class="form-input"
placeholder="输入表单标题"
/>
</div>
<div class="form-group">
<label for="form-description">表单描述</label>
<textarea
id="form-description"
:value="formData.form_description"
@input="updateFormData('form_description', ($event.target as HTMLTextAreaElement).value)"
class="form-textarea"
placeholder="输入表单描述(可选)"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="submit-button-text">提交按钮文字</label>
<input
id="submit-button-text"
:value="formData.submit_button_text"
@input="updateFormData('submit_button_text', ($event.target as HTMLInputElement).value)"
type="text"
class="form-input"
placeholder="提交"
/>
</div>
<div class="form-group">
<label class="checkbox-group">
<input
:checked="formData.form_full_screen"
@change="updateFormData('form_full_screen', ($event.target as HTMLInputElement).checked)"
type="checkbox"
class="form-checkbox"
/>
<span class="checkbox-label">全屏显示表单</span>
</label>
<p class="help-text">启用后,表单将以全屏浮层显示,而不是显示在聊天界面内</p>
</div>
<div class="form-group">
<label>表单字段</label>
<div class="form-fields-container" :class="{ 'error': validationErrors.form_fields }">
<div
v-for="(field, index) in formData.form_fields"
:key="index"
class="form-field-item"
>
<div class="form-field-header">
<span class="field-type-badge" :class="`field-type-${field.type}`">
{{ getFieldTypeIcon(field.type) }} {{ field.type }}
</span>
<button
type="button"
class="remove-field-btn"
@click="removeFormField(index)"
title="删除字段"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="form-field-config">
<div class="field-row">
<div class="field-col">
<label class="field-label">字段名 *</label>
<input
:value="field.name"
@input="updateField(index, 'name', ($event.target as HTMLInputElement).value)"
@blur="$emit('validate-field', 'form_fields')"
type="text"
class="field-input"
placeholder="field_name"
required
/>
</div>
<div class="field-col">
<label class="field-label">显示名称 *</label>
<input
:value="field.label"
@input="updateField(index, 'label', ($event.target as HTMLInputElement).value)"
@blur="$emit('validate-field', 'form_fields')"
type="text"
class="field-input"
placeholder="字段名称"
required
/>
</div>
</div>
<div class="field-row">
<div class="field-col">
<label class="field-label">字段类型</label>
<select
:value="field.type"
@change="updateField(index, 'type', ($event.target as HTMLSelectElement).value)"
class="field-select"
>
<option value="text">文本</option>
<option value="email">邮箱</option>
<option value="number">数字</option>
<option value="tel">电话</option>
<option value="textarea">多行文本</option>
<option value="select">下拉选择</option>
<option value="checkbox">复选框</option>
<option value="radio">单选框</option>
</select>
</div>
<div class="field-col">
<label class="field-label">必填</label>
<label class="checkbox-label">
<input
:checked="field.required"
@change="updateField(index, 'required', ($event.target as HTMLInputElement).checked)"
type="checkbox"
class="form-checkbox"
/>
<span>必填字段</span>
</label>
</div>
</div>
<div class="field-row">
<div class="field-col-full">
<label class="field-label">占位提示</label>
<input
:value="field.placeholder"
@input="updateField(index, 'placeholder', ($event.target as HTMLInputElement).value)"
@blur="$emit('validate-field', 'form_fields')"
type="text"
class="field-input"
placeholder="输入占位提示文字"
/>
</div>
</div>
<!-- Options for select/radio fields -->
<div v-if="field.type === 'select' || field.type === 'radio'" class="field-row">
<div class="field-col-full">
<label class="field-label">选项(每行一个)</label>
<textarea
:value="Array.isArray(field.options) ? field.options.join('\n') : field.options"
@input="updateField(index, 'options', ($event.target as HTMLTextAreaElement).value)"
@blur="$emit('validate-field', 'form_fields')"
class="field-textarea"
placeholder="选项 1&#10;选项 2&#10;选项 3"
rows="3"
></textarea>
</div>
</div>
<!-- Validation for text fields -->
<div v-if="field.type === 'text' || field.type === 'textarea'" class="field-row">
<div class="field-col">
<label class="field-label">最小长度</label>
<input
:value="field.minLength"
@input="updateField(index, 'minLength', parseInt(($event.target as HTMLInputElement).value) || 0)"
@blur="$emit('validate-field', 'form_fields')"
type="number"
class="field-input"
min="0"
placeholder="0"
/>
</div>
<div class="field-col">
<label class="field-label">最大长度</label>
<input
:value="field.maxLength"
@input="updateField(index, 'maxLength', parseInt(($event.target as HTMLInputElement).value) || 255)"
@blur="$emit('validate-field', 'form_fields')"
type="number"
class="field-input"
min="1"
placeholder="255"
/>
</div>
</div>
</div>
</div>
<button
type="button"
class="add-field-btn"
@click="addFormField"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
添加表单字段
</button>
</div>
<div v-if="validationErrors.form_fields" class="error-message">
{{ validationErrors.form_fields }}
</div>
</div>
</div>
</template>
<style scoped>
.form-node-config {
width: 100%;
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-textarea {
resize: vertical;
min-height: 70px;
}
.checkbox-group {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0;
}
.checkbox-group input[type="checkbox"] {
margin-top: 2px;
flex-shrink: 0;
}
.checkbox-label {
font-size: 0.85rem;
color: var(--text-color);
line-height: 1.4;
cursor: pointer;
user-select: none;
}
.form-checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
.help-text {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 4px;
line-height: 1.3;
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
/* Form Fields Styles */
.form-fields-container {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
padding: var(--space-sm);
max-height: none;
overflow: visible;
}
.form-fields-container.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-field-item {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-soft);
margin-bottom: var(--space-sm);
overflow: hidden;
}
.form-field-item:last-child {
margin-bottom: 0;
}
.form-field-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-sm) var(--space-md);
background: var(--background-muted);
border-bottom: 1px solid var(--border-color);
}
.field-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 500;
text-transform: capitalize;
background: var(--primary-soft);
color: var(--primary-color);
}
.remove-field-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.remove-field-btn:hover {
background: var(--error-color);
color: white;
}
.remove-field-btn svg {
width: 12px;
height: 12px;
}
.form-field-config {
padding: var(--space-md);
}
.field-row {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.field-row:last-child {
margin-bottom: 0;
}
.field-col {
flex: 1;
}
.field-col-full {
flex: 1;
width: 100%;
}
.field-label {
display: block;
font-size: 0.7rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 4px;
}
.field-input,
.field-textarea,
.field-select {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--background-color);
color: var(--text-color);
font-size: 0.75rem;
transition: border-color 0.2s ease;
}
.field-input:focus,
.field-textarea:focus,
.field-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 1px rgba(201, 242, 78, 0.15);
}
.field-input.error,
.field-textarea.error,
.field-select.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.field-textarea {
resize: vertical;
min-height: 60px;
}
.add-field-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
width: 100%;
padding: var(--space-sm);
background: var(--background-muted);
border: 1px dashed var(--border-color);
border-radius: var(--radius-md);
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.8rem;
font-weight: 500;
}
.add-field-btn:hover {
background: var(--primary-soft);
border-color: var(--primary-color);
color: var(--primary-color);
}
.add-field-btn svg {
width: 14px;
height: 14px;
}
</style>
@@ -0,0 +1,534 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
interface GuardrailsNodeData {
enabled_guardrails: string[]
pii_action: string
jailbreak_sensitivity: number
text_source: string
block_message: string
}
interface Variable {
nodeId: string
nodeName: string
fieldName: string
fieldType: string
fieldLabel: string
}
const props = defineProps<{
modelValue: GuardrailsNodeData
validationErrors: Record<string, string>
availableVariables?: Variable[]
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: GuardrailsNodeData): void
(e: 'validate-field', field: string): void
}>()
const formData = computed({
get: () => props.modelValue,
set: (value: GuardrailsNodeData) => emit('update:model-value', value)
})
// Update form data
const updateFormData = (field: keyof GuardrailsNodeData, value: any) => {
formData.value = {
...formData.value,
[field]: value
}
emit('validate-field', field)
}
// Toggle guardrail type
const toggleGuardrail = (type: string) => {
const current = formData.value.enabled_guardrails || []
const index = current.indexOf(type)
let updated: string[]
if (index > -1) {
// Remove if exists
updated = current.filter(g => g !== type)
} else {
// Add if doesn't exist
updated = [...current, type]
}
updateFormData('enabled_guardrails', updated)
}
const isGuardrailEnabled = (type: string) => {
return (formData.value.enabled_guardrails || []).includes(type)
}
// Helper to get variable syntax
const getVariableSyntax = (fieldName: string) => {
return `{{${fieldName}}}`
}
// Copy variable to clipboard
const copyVariableToClipboard = async (variable: Variable) => {
const syntax = getVariableSyntax(variable.fieldName)
try {
await navigator.clipboard.writeText(syntax)
// Could add a toast notification here if needed
} catch (err) {
console.error('Failed to copy variable:', err)
}
}
</script>
<template>
<div class="guardrails-node-config">
<div class="info-box">
<p>配置内容护栏,检测并拦截不当内容。</p>
</div>
<div class="form-group">
<label>启用的护栏 *</label>
<div class="guardrail-options">
<label class="guardrail-option">
<input
type="checkbox"
:checked="isGuardrailEnabled('pii')"
@change="toggleGuardrail('pii')"
class="form-checkbox"
/>
<div class="guardrail-info">
<span class="guardrail-icon">🔒</span>
<div>
<strong>PII 检测</strong>
<p>检测个人身份信息(邮箱、电话号码、身份证号等)</p>
</div>
</div>
</label>
<label class="guardrail-option">
<input
type="checkbox"
:checked="isGuardrailEnabled('jailbreak')"
@change="toggleGuardrail('jailbreak')"
class="form-checkbox"
/>
<div class="guardrail-info">
<span class="guardrail-icon">⚠️</span>
<div>
<strong>越狱检测</strong>
<p>检测绕过 AI 安全准则或操纵系统提示词的尝试</p>
</div>
</div>
</label>
</div>
<div v-if="validationErrors.enabled_guardrails" class="error-message">
{{ validationErrors.enabled_guardrails }}
</div>
</div>
<!-- PII Action -->
<div v-if="isGuardrailEnabled('pii')" class="form-group">
<label for="pii-action">PII 处理方式</label>
<select
id="pii-action"
:value="formData.pii_action"
@change="updateFormData('pii_action', ($event.target as HTMLSelectElement).value)"
class="form-select"
>
<option value="block">阻止:检测到 PII 时停止处理</option>
<option value="redact">脱敏:将 PII 替换为占位符</option>
<option value="warning">警告:记录日志但允许继续处理</option>
<option value="log">记录:仅记录检测结果</option>
</select>
</div>
<!-- Jailbreak Sensitivity -->
<div v-if="isGuardrailEnabled('jailbreak')" class="form-group">
<label for="jailbreak-sensitivity">
越狱检测灵敏度:{{ formData.jailbreak_sensitivity?.toFixed(1) || '0.7' }}
</label>
<input
id="jailbreak-sensitivity"
type="range"
:value="formData.jailbreak_sensitivity || 0.7"
@input="updateFormData('jailbreak_sensitivity', parseFloat(($event.target as HTMLInputElement).value))"
min="0.1"
max="1.0"
step="0.1"
class="form-range"
/>
<div class="range-labels">
<span>较低灵敏度(0.1)</span>
<span>较高灵敏度(1.0)</span>
</div>
<small class="help-text">
灵敏度越高,越容易检测到潜在的越狱尝试
</small>
</div>
<div class="form-group">
<label for="text-source">文本来源</label>
<input
id="text-source"
type="text"
:value="formData.text_source"
@input="updateFormData('text_source', ($event.target as HTMLInputElement).value)"
class="form-input"
placeholder="user_message or user_input_input"
/>
<small class="help-text">
指定要检测的文本。使用“user_message”表示最新用户输入,也可引用“user_input_input”这样的变量,或使用双花括号语法
</small>
</div>
<!-- Available Variables Section -->
<div v-if="availableVariables && availableVariables.length > 0" class="variables-section">
<div class="variables-header">
<span class="variables-title">可用变量</span>
<span class="variables-count">{{ availableVariables.length }}</span>
</div>
<div class="variables-list">
<div
v-for="variable in availableVariables"
:key="`${variable.nodeId}-${variable.fieldName}`"
class="variable-item"
@click="copyVariableToClipboard(variable)"
:title="`点击复制 ${getVariableSyntax(variable.fieldName)} 到剪贴板`"
>
<div class="variable-info">
<div class="variable-name">{{ variable.fieldName }}</div>
<div class="variable-source">来自 {{ variable.nodeName }}</div>
</div>
<div class="variable-syntax">
<code>{{ getVariableSyntax(variable.fieldName) }}</code>
</div>
</div>
</div>
<div class="variables-help">
<small>点击任意变量即可复制到剪贴板</small>
</div>
</div>
<div class="form-group">
<label for="block-message">自定义拦截消息</label>
<textarea
id="block-message"
:value="formData.block_message"
@input="updateFormData('block_message', ($event.target as HTMLTextAreaElement).value)"
class="form-textarea"
placeholder="可选:内容被拦截时显示的自定义消息"
rows="3"
></textarea>
<small class="help-text">
留空则使用默认拦截消息
</small>
</div>
</div>
</template>
<style scoped>
.guardrails-node-config {
width: 100%;
}
.info-box {
background: var(--color-bg-tertiary, #f8f9fa);
border-left: 3px solid var(--color-primary, #3b82f6);
padding: var(--space-xs);
margin-bottom: var(--space-sm);
border-radius: 4px;
}
.info-box p {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-secondary, #6b7280);
}
.info-box ul {
margin: var(--space-xs) 0 0 0;
padding-left: 1.5rem;
}
.info-box li {
margin: 0.25rem 0;
font-size: 0.85rem;
}
.routing-info {
border-left-color: var(--color-accent, #ec4899);
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-primary, #1f2937);
margin-bottom: var(--space-xs);
}
.guardrail-options {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.guardrail-option {
display: flex;
align-items: flex-start;
gap: var(--space-xs);
padding: var(--space-xs);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.guardrail-option:hover {
background: var(--color-bg-secondary, #f9fafb);
border-color: var(--color-primary, #3b82f6);
}
.guardrail-option input[type="checkbox"] {
margin-top: 0.2rem;
}
.guardrail-info {
display: flex;
align-items: flex-start;
gap: var(--space-xs);
flex: 1;
}
.guardrail-icon {
font-size: 1.5rem;
line-height: 1;
}
.guardrail-info strong {
display: block;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.guardrail-info p {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-secondary, #6b7280);
font-weight: normal;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: var(--space-xs);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.85rem;
transition: border-color 0.2s;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--color-primary, #3b82f6);
}
.form-range {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--color-bg-tertiary, #e5e7eb);
outline: none;
-webkit-appearance: none;
}
.form-range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
}
.form-range::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-primary, #3b82f6);
cursor: pointer;
border: none;
}
.range-labels {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
}
.range-labels span {
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
}
.help-text {
display: block;
font-size: 0.75rem;
color: var(--color-text-secondary, #6b7280);
margin-top: 0.25rem;
}
.error-message {
color: var(--color-error, #ef4444);
font-size: 0.75rem;
margin-top: 0.25rem;
}
.form-checkbox {
cursor: pointer;
}
/* Variables Section */
.variables-section {
margin-top: var(--space-sm);
padding: var(--space-sm);
background: var(--background-soft);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.variables-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-xs);
}
.variables-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
}
.variables-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: var(--accent-solid);
color: var(--on-accent-solid);
border-radius: 10px;
font-size: 0.7rem;
font-weight: 600;
}
.variables-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.variable-item {
display: flex;
align-items: flex-start;
gap: var(--space-xs);
padding: var(--space-sm);
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
}
.variable-item:hover {
background: var(--background-alt);
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.variable-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.variable-name {
font-weight: 600;
font-size: var(--text-sm);
color: var(--text-color);
word-break: break-word;
}
.variable-source {
font-size: 0.75rem;
color: var(--text-muted);
margin: 2px 0;
}
.variable-syntax {
display: flex;
align-items: center;
flex-shrink: 0;
margin-top: 2px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.variable-syntax code {
background: var(--background-soft);
padding: 2px 6px;
border-radius: var(--radius-xs);
font-size: 0.75rem;
color: var(--text-color);
border: 1px solid var(--border-color);
white-space: nowrap;
}
.variables-help {
padding: var(--space-sm);
background: var(--background-soft);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
margin-top: var(--space-xs);
}
.variables-help small {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.3;
}
</style>
@@ -0,0 +1,145 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
defineProps<{
data: Node['data']
}>()
</script>
<template>
<div class="human-transfer-node">
<Handle
id="input"
type="target"
:position="Position.Left"
class="node-handle"
/>
<div class="node-content">
<div class="node-header">
<div class="node-icon">👤</div>
<div class="node-title">{{ data.label || '转人工' }}</div>
</div>
<div v-if="data.description" class="node-description">
{{ data.description }}
</div>
<div v-if="data.config?.department" class="node-preview">
<div class="preview-label">部门:</div>
<div class="preview-content">{{ data.config.department }}</div>
</div>
<div v-if="data.config?.message" class="node-preview">
<div class="preview-label">转接消息:</div>
<div class="preview-content">
{{ data.config.message.substring(0, 80) }}{{ data.config.message.length > 80 ? '...' : '' }}
</div>
</div>
</div>
<Handle
id="output"
type="source"
:position="Position.Right"
class="node-handle"
/>
</div>
</template>
<style scoped>
.human-transfer-node {
position: relative;
background: var(--background-color);
border: 2px solid #F97316;
border-radius: var(--radius-lg);
padding: var(--space-md);
min-width: 200px;
max-width: 300px;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
.human-transfer-node:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.node-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.node-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.node-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-sm);
}
.node-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.3;
}
.node-preview {
background: var(--background-soft);
border-radius: var(--radius-sm);
padding: var(--space-sm);
border: 1px solid var(--border-color);
}
.preview-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 2px;
}
.preview-content {
font-size: 0.7rem;
color: var(--text-color);
line-height: 1.3;
}
.node-handle {
background: #F97316;
border: 2px solid var(--background-color);
width: 12px;
height: 12px;
}
.node-handle:hover {
background: #EA580C;
transform: scale(1.2);
}
</style>
@@ -0,0 +1,154 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
interface HumanTransferNodeData {
transfer_department: string
transfer_message: string
}
const props = defineProps<{
modelValue: HumanTransferNodeData
validationErrors: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: HumanTransferNodeData): void
(e: 'validate-field', field: string): void
}>()
const formData = computed({
get: () => props.modelValue,
set: (value: HumanTransferNodeData) => emit('update:model-value', value)
})
// Update form data
const updateFormData = (field: keyof HumanTransferNodeData, value: any) => {
formData.value = {
...formData.value,
[field]: value
}
emit('validate-field', field)
}
</script>
<template>
<div class="human-transfer-node-config">
<div class="form-group">
<label for="transfer-department">部门 *</label>
<select
id="transfer-department"
:value="formData.transfer_department"
@change="updateFormData('transfer_department', ($event.target as HTMLSelectElement).value)"
@blur="$emit('validate-field', 'transfer_department')"
class="form-select"
:class="{ 'error': validationErrors.transfer_department }"
required
>
<option value="">选择部门</option>
<option value="general">常规支持</option>
<option value="sales">销售</option>
<option value="technical">技术支持</option>
</select>
<div v-if="validationErrors.transfer_department" class="error-message">
{{ validationErrors.transfer_department }}
</div>
</div>
<div class="form-group">
<label for="transfer-message">转接消息</label>
<textarea
id="transfer-message"
:value="formData.transfer_message"
@input="updateFormData('transfer_message', ($event.target as HTMLTextAreaElement).value)"
class="form-textarea"
placeholder="转接至人工客服时显示的消息"
rows="3"
></textarea>
</div>
</div>
</template>
<style scoped>
.human-transfer-node-config {
width: 100%;
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-select,
.form-textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
}
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-select.error,
.form-textarea.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-select.error:focus,
.form-textarea.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 70px;
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
</style>
@@ -0,0 +1,145 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
defineProps<{
data: Node['data']
}>()
</script>
<template>
<div class="llm-node">
<Handle
id="input"
type="target"
:position="Position.Left"
class="node-handle"
/>
<div class="node-content">
<div class="node-header">
<div class="node-icon">🤖</div>
<div class="node-title">{{ data.label || 'LLM' }}</div>
</div>
<div v-if="data.description" class="node-description">
{{ data.description }}
</div>
<div v-if="data.config?.model" class="node-preview">
<div class="preview-label">模型:</div>
<div class="preview-content">{{ data.config.model }}</div>
</div>
<div v-if="data.config?.systemPrompt" class="node-preview">
<div class="preview-label">系统提示词:</div>
<div class="preview-content">
{{ data.config.systemPrompt.substring(0, 80) }}{{ data.config.systemPrompt.length > 80 ? '...' : '' }}
</div>
</div>
</div>
<Handle
id="output"
type="source"
:position="Position.Right"
class="node-handle"
/>
</div>
</template>
<style scoped>
.llm-node {
position: relative;
background: var(--background-color);
border: 2px solid #8B5CF6;
border-radius: var(--radius-lg);
padding: var(--space-md);
min-width: 200px;
max-width: 300px;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
.llm-node:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.node-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.node-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.node-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-sm);
}
.node-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.3;
}
.node-preview {
background: var(--background-soft);
border-radius: var(--radius-sm);
padding: var(--space-sm);
border: 1px solid var(--border-color);
}
.preview-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 2px;
}
.preview-content {
font-size: 0.7rem;
color: var(--text-color);
line-height: 1.3;
}
.node-handle {
background: #8B5CF6;
border: 2px solid var(--background-color);
width: 12px;
height: 12px;
}
.node-handle:hover {
background: #7C3AED;
transform: scale(1.2);
}
</style>
@@ -0,0 +1,859 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useAgentEdit } from '@/composables/useAgentEdit'
import KnowledgeGrid from '@/components/agent/KnowledgeGrid.vue'
import { ExitCondition } from '@/types/workflow'
import type { UserGroup } from '@/types/user'
interface FormData {
system_prompt: string
temperature: number
exit_condition: string
auto_transfer_enabled: boolean
transfer_group_id: string
ask_for_rating: boolean
}
const props = defineProps<{
modelValue: FormData
validationErrors: Record<string, string>
userGroups: UserGroup[]
loadingGroups: boolean
agentId: string
organizationId: string
collapsedSections: Record<string, boolean>
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: FormData): void
(e: 'validateField', field: string): void
(e: 'toggleSection', section: string): void
}>()
// Local reactive copy of the form data
const localFormData = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// AI generation state
const showAIPrompt = ref(false)
const aiPrompt = ref('')
// Initialize agent edit composable (using a mock agent object for AI generation)
const mockAgent = {
id: 'temp-agent',
name: 'Workflow Node Agent',
display_name: 'Workflow Node Agent',
description: 'Temporary agent for AI generation',
agent_type: 'general',
instructions: '',
transfer_to_human: false,
ask_for_rating: false,
organization_id: 'temp',
created_by: 'temp',
created_at: new Date(),
updated_at: new Date(),
is_active: true,
enable_rate_limiting: false,
overall_limit_per_ip: 0,
requests_per_sec: 0,
conversation_starters: [],
knowledge_base_ids: [],
canvas_data: {}
} as any
const { generateInstructions, isLoading: aiLoading, error: aiError } = useAgentEdit(mockAgent)
// Handle field updates
const updateField = (field: keyof FormData, value: any) => {
const updated = { ...localFormData.value, [field]: value }
emit('update:modelValue', updated)
emit('validateField', field)
}
// Handle AI generation for system prompt
const handleGenerateWithAI = async () => {
if (!aiPrompt.value.trim()) return
try {
const generatedInstructions = await generateInstructions(aiPrompt.value)
if (generatedInstructions.length > 0) {
// Join the generated instructions with newlines
updateField('system_prompt', generatedInstructions.join('\n'))
showAIPrompt.value = false
aiPrompt.value = ''
}
} catch (err) {
console.error('Failed to generate instructions:', err)
}
}
const validateFieldOnChange = (field: string) => {
emit('validateField', field)
}
const toggleSection = (section: string) => {
emit('toggleSection', section)
}
</script>
<template>
<div class="llm-node-config">
<div class="form-group">
<div class="instructions-header">
<label for="system-prompt">指令 *</label>
<button
class="ai-generate-button"
@click="showAIPrompt = true"
:disabled="aiLoading"
type="button"
>
<span class="ai-icon">✨</span>
AI 生成
</button>
</div>
<textarea
id="system-prompt"
:value="localFormData.system_prompt"
@input="updateField('system_prompt', ($event.target as HTMLInputElement).value)"
class="form-textarea"
:class="{ 'error': validationErrors.system_prompt }"
placeholder="输入给 AI 的系统提示词"
rows="4"
required
@blur="validateFieldOnChange('system_prompt')"
></textarea>
<div v-if="validationErrors.system_prompt" class="error-message">
{{ validationErrors.system_prompt }}
</div>
</div>
<div class="form-group">
<label for="temperature">温度</label>
<input
id="temperature"
:value="localFormData.temperature"
@input="updateField('temperature', parseFloat(($event.target as HTMLInputElement).value))"
type="number"
class="form-input"
:class="{ 'error': validationErrors.temperature }"
min="0"
max="2"
step="0.1"
placeholder="0.7"
@blur="validateFieldOnChange('temperature')"
/>
<div v-if="validationErrors.temperature" class="error-message">
{{ validationErrors.temperature }}
</div>
<p class="help-text">
控制回复的随机性。数值越低(0.1)越聚焦,数值越高(1.5)越有创造性。
</p>
</div>
<div class="form-group">
<label for="exit-condition">退出条件 *</label>
<select
id="exit-condition"
:value="localFormData.exit_condition"
@change="updateField('exit_condition', ($event.target as HTMLSelectElement).value)"
class="form-select"
:class="{ 'error': validationErrors.exit_condition }"
required
@blur="validateFieldOnChange('exit_condition')"
>
<option :value="ExitCondition.SINGLE_EXECUTION">单次执行</option>
<option :value="ExitCondition.CONTINUOUS_EXECUTION">持续执行</option>
</select>
<div v-if="validationErrors.exit_condition" class="error-message">
{{ validationErrors.exit_condition }}
</div>
<p class="help-text">
<template v-if="localFormData.exit_condition === ExitCondition.SINGLE_EXECUTION">
AI 回复一次后总是进入下一个节点(默认)。
</template>
<template v-else-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION">
停留在当前节点持续对话,直到请求结束会话。
</template>
</p>
</div>
<!-- Transfer to Human Setting - Only for Continuous Execution -->
<div v-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION" class="form-group">
<label class="checkbox-group">
<input
:checked="localFormData.auto_transfer_enabled"
@change="updateField('auto_transfer_enabled', ($event.target as HTMLInputElement).checked)"
type="checkbox"
class="form-checkbox"
/>
<span class="checkbox-label">启用自动转人工</span>
</label>
<p class="help-text">
启用后,AI 判断需要人工协助时可自动将会话转接给人工客服,且不会结束会话。
</p>
<!-- Group selection - Only when transfer is enabled -->
<div v-if="localFormData.auto_transfer_enabled" class="transfer-groups">
<label for="transfer-group">转接分组</label>
<div v-if="!loadingGroups">
<select
id="transfer-group"
:value="localFormData.transfer_group_id"
@change="updateField('transfer_group_id', ($event.target as HTMLSelectElement).value)"
class="form-select"
>
<option value="">选择分组</option>
<option v-for="group in userGroups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
<p v-if="userGroups.length === 0" class="help-text no-groups-message">
暂无可用分组。
<router-link to="/human-agents" class="create-group-link">
创建分组
</router-link>
</p>
<p v-else class="help-text">
选择负责处理转接会话的分组。
</p>
</div>
<div v-else class="loading-groups">
正在加载分组……
</div>
</div>
</div>
<!-- Request Rating Setting - Only for Continuous Execution -->
<div v-if="localFormData.exit_condition === ExitCondition.CONTINUOUS_EXECUTION" class="form-group">
<label class="checkbox-group">
<input
:checked="localFormData.ask_for_rating"
@change="updateField('ask_for_rating', ($event.target as HTMLInputElement).checked)"
type="checkbox"
class="form-checkbox"
/>
<span class="checkbox-label">会话结束后邀请评分</span>
</label>
<p class="help-text">
启用后,会话结束时将邀请客户对体验进行评分。
</p>
</div>
<!-- Knowledge Section -->
<div class="form-group">
<div class="knowledge-section">
<div class="section-header knowledge-header" @click="toggleSection('knowledge')">
<div class="section-title">
<svg class="section-icon" :class="{ 'rotated': collapsedSections.knowledge }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6,9 12,15 18,9"></polyline>
</svg>
<span>知识来源</span>
</div>
</div>
<div class="section-content knowledge-content" :class="{ 'collapsed': collapsedSections.knowledge }">
<div class="knowledge-wrapper">
<KnowledgeGrid
v-if="!collapsedSections.knowledge"
:agent-id="agentId"
:organization-id="organizationId"
/>
</div>
</div>
</div>
</div>
<!-- AI Prompt Modal -->
<div v-if="showAIPrompt" class="ai-prompt-modal">
<div class="ai-prompt-content">
<h5>用 AI 生成指令</h5>
<textarea
v-model="aiPrompt"
placeholder="描述你希望这个 AI 节点做什么。例如:“为一个帮助查询订单的客服助手生成指令”"
rows="4"
class="ai-prompt-textarea"
></textarea>
<div v-if="aiError" class="error-message">{{ aiError }}</div>
<div class="ai-prompt-actions">
<button
class="cancel-ai-button"
@click="showAIPrompt = false"
:disabled="aiLoading"
type="button"
>
取消
</button>
<button
class="generate-ai-button"
@click="handleGenerateWithAI"
:disabled="aiLoading || !aiPrompt.trim()"
type="button"
>
{{ aiLoading ? '生成中……' : '生成' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Import shared form styles */
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-input.error,
.form-textarea.error,
.form-select.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-input.error:focus,
.form-textarea.error:focus,
.form-select.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
.form-textarea {
resize: vertical;
min-height: 70px;
}
.checkbox-group {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 8px 0;
}
.checkbox-group input[type="checkbox"] {
margin-top: 2px;
flex-shrink: 0;
}
.checkbox-label {
font-size: 0.85rem;
color: var(--text-color);
line-height: 1.4;
}
.help-text {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 4px;
line-height: 1.3;
}
.form-checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
/* Instructions Header Styles */
.instructions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.ai-generate-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.ai-generate-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.ai-generate-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ai-icon {
font-size: 0.8rem;
}
/* AI Prompt Modal Styles */
.ai-prompt-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.ai-prompt-content {
background: var(--background-color);
border-radius: var(--radius-lg);
padding: var(--space-lg);
width: 90%;
max-width: 500px;
box-shadow: var(--shadow-xl);
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.ai-prompt-content h5 {
margin: 0 0 var(--space-sm) 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
.ai-prompt-textarea {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-soft);
color: var(--text-color);
font-size: 0.85rem;
resize: vertical;
min-height: 100px;
box-sizing: border-box;
}
.ai-prompt-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-sm);
}
.cancel-ai-button,
.generate-ai-button {
padding: 8px 16px;
border: none;
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.85rem;
min-width: 80px;
white-space: nowrap;
}
.cancel-ai-button {
background: var(--background-muted);
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.cancel-ai-button:hover {
background: var(--background-alt);
color: var(--text-color);
}
.generate-ai-button {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.generate-ai-button:hover {
background: var(--primary-dark);
}
.generate-ai-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Transfer groups styling */
.transfer-groups {
margin-top: var(--space-md);
padding: var(--space-md);
background: var(--background-alt);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
.transfer-groups label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.no-groups-message {
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 4px;
}
.create-group-link {
color: var(--primary-color);
text-decoration: none;
font-weight: 500;
}
.create-group-link:hover {
text-decoration: underline;
}
.loading-groups {
text-align: center;
padding: var(--space-sm);
color: var(--text-muted);
font-size: 0.8rem;
}
/* Knowledge Section Styles */
.knowledge-section {
margin-top: var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
overflow: hidden;
}
.knowledge-header .section-header {
background: var(--background-muted);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
cursor: pointer;
transition: all 0.2s ease;
}
.knowledge-header .section-header:hover {
background: var(--background-alt);
}
.section-title {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
}
.section-icon {
width: 16px;
height: 16px;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.section-icon.rotated {
transform: rotate(-90deg);
}
.knowledge-content {
padding: 0;
max-height: none;
overflow: visible;
transition: all 0.3s ease;
}
.knowledge-content.collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
overflow: hidden;
}
.knowledge-wrapper {
position: relative;
width: 100%;
height: 100%;
}
/* Override KnowledgeGrid styles within LLMNodeConfig */
.knowledge-content :deep(.knowledge-grid-container) {
padding: var(--space-sm);
background: transparent;
border-top: none;
}
.knowledge-content :deep(.knowledge-header) {
margin-bottom: var(--space-sm);
flex-direction: column;
gap: var(--space-xs);
align-items: stretch;
}
.knowledge-content :deep(.header-left) {
flex-direction: column;
gap: var(--space-xs);
align-items: stretch;
}
.knowledge-content :deep(.header-left h3) {
font-size: 1rem;
margin-bottom: 0;
}
.knowledge-content :deep(.header-actions) {
display: flex;
gap: var(--space-xs);
justify-content: center;
}
.knowledge-content :deep(.action-button) {
font-size: 0.7rem;
padding: 4px 8px;
flex: 1;
text-align: center;
white-space: nowrap;
}
.knowledge-content :deep(.knowledge-grid) {
font-size: 0.8rem;
}
.knowledge-content :deep(.knowledge-grid-header),
.knowledge-content :deep(.knowledge-grid-row) {
grid-template-columns: 1fr 60px;
min-height: auto;
}
.knowledge-content :deep(.header-cell),
.knowledge-content :deep(.grid-cell) {
padding: var(--space-xs) var(--space-sm);
font-size: 0.75rem;
}
.knowledge-content :deep(.header-cell:nth-child(2)),
.knowledge-content :deep(.header-cell:nth-child(3)),
.knowledge-content :deep(.header-cell:nth-child(4)),
.knowledge-content :deep(.grid-cell:nth-child(2)),
.knowledge-content :deep(.grid-cell:nth-child(3)),
.knowledge-content :deep(.grid-cell:nth-child(4)) {
display: none;
}
.knowledge-content :deep(.actions-cell) {
width: 60px;
text-align: center;
}
.knowledge-content :deep(.delete-button) {
padding: 2px;
}
.knowledge-content :deep(.delete-icon) {
width: 16px;
height: 16px;
}
.knowledge-content :deep(.knowledge-empty) {
padding: var(--space-md);
text-align: center;
}
.knowledge-content :deep(.warning-message) {
font-size: 0.8rem;
margin-bottom: var(--space-xs);
}
.knowledge-content :deep(.warning-description) {
font-size: 0.75rem;
line-height: 1.4;
}
.knowledge-content :deep(.modal-content) {
max-width: 30vw;
max-height: 75vh;
width: 65%;
}
/* Ensure knowledge modals have proper z-index */
.knowledge-content :deep(.modal-overlay) {
z-index: 1001 !important;
position: fixed !important;
isolation: isolate;
background: rgba(0, 0, 0, 0.4) !important;
}
.knowledge-content :deep(.modal-overlay),
.knowledge-content :deep(.modal-content) {
pointer-events: auto;
}
.knowledge-content :deep(.modal-overlay) {
backdrop-filter: blur(4px);
}
.knowledge-content :deep(.pagination) {
flex-direction: column;
gap: var(--space-xs);
margin-top: var(--space-sm);
}
.knowledge-content :deep(.pagination-button) {
width: 100%;
font-size: 0.7rem;
padding: var(--space-xs);
}
.knowledge-content :deep(.page-info) {
font-size: 0.7rem;
text-align: center;
}
/* Link modal overrides */
.knowledge-content :deep(.link-modal) {
max-width: 65vw;
width: 60%;
}
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header),
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) {
grid-template-columns: 2fr 0fr 100px;
align-items: center;
}
.knowledge-content :deep(.org-knowledge-grid .type-cell) {
display: none;
}
.knowledge-content :deep(.source-cell) {
padding-left: var(--space-sm);
white-space: normal;
word-break: break-word;
font-size: 0.75rem;
}
.knowledge-content :deep(.action-cell) {
padding-right: var(--space-sm);
text-align: right;
width: 100px;
}
.knowledge-content :deep(.link-button),
.knowledge-content :deep(.unlink-button) {
min-width: 70px;
padding: 4px 8px;
font-size: 0.7rem;
display: inline-block;
}
.knowledge-content :deep(.org-knowledge-grid) .action-cell {
display: block !important;
visibility: visible !important;
}
.knowledge-content :deep(.org-knowledge-grid) .link-button,
.knowledge-content :deep(.org-knowledge-grid) .unlink-button {
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
}
@media (max-width: 768px) {
.knowledge-content :deep(.knowledge-grid-container) {
padding: 4px;
}
.knowledge-content :deep(.header-actions) {
flex-direction: column;
gap: 4px;
}
.knowledge-content :deep(.action-button) {
font-size: 0.65rem;
padding: 3px 6px;
}
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-header),
.knowledge-content :deep(.org-knowledge-grid .knowledge-grid-row) {
grid-template-columns: 2fr 0fr 100px !important;
}
.knowledge-content :deep(.org-knowledge-grid) .action-cell {
display: block !important;
grid-column: 3 !important;
}
}
</style>
@@ -0,0 +1,156 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
interface LandingPageNodeData {
landing_page_heading: string
landing_page_content: string
}
const props = defineProps<{
modelValue: LandingPageNodeData
validationErrors: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: LandingPageNodeData): void
(e: 'validate-field', field: string): void
}>()
const formData = computed({
get: () => props.modelValue,
set: (value: LandingPageNodeData) => emit('update:model-value', value)
})
// Update form data
const updateFormData = (field: keyof LandingPageNodeData, value: any) => {
formData.value = {
...formData.value,
[field]: value
}
emit('validate-field', field)
}
</script>
<template>
<div class="landing-page-node-config">
<div class="form-group">
<label for="landing-page-heading">标题 *</label>
<input
id="landing-page-heading"
:value="formData.landing_page_heading"
@input="updateFormData('landing_page_heading', ($event.target as HTMLInputElement).value)"
@blur="$emit('validate-field', 'landing_page_heading')"
class="form-input"
:class="{ 'error': validationErrors.landing_page_heading }"
placeholder="输入落地页的欢迎标题"
required
/>
<div v-if="validationErrors.landing_page_heading" class="error-message">
{{ validationErrors.landing_page_heading }}
</div>
</div>
<div class="form-group">
<label for="landing-page-content">内容 *</label>
<textarea
id="landing-page-content"
:value="formData.landing_page_content"
@input="updateFormData('landing_page_content', ($event.target as HTMLTextAreaElement).value)"
@blur="$emit('validate-field', 'landing_page_content')"
class="form-textarea"
:class="{ 'error': validationErrors.landing_page_content }"
placeholder="输入落地页内容,将显示在标题下方。"
rows="4"
required
></textarea>
<div v-if="validationErrors.landing_page_content" class="error-message">
{{ validationErrors.landing_page_content }}
</div>
</div>
</div>
</template>
<style scoped>
.landing-page-node-config {
width: 100%;
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-input,
.form-textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-input.error,
.form-textarea.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-input.error:focus,
.form-textarea.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.form-textarea {
resize: vertical;
min-height: 70px;
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
</style>
@@ -0,0 +1,140 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
defineProps<{
data: Node['data']
}>()
</script>
<template>
<div class="message-node">
<Handle
id="input"
type="target"
:position="Position.Left"
class="node-handle"
/>
<div class="node-content">
<div class="node-header">
<div class="node-icon">💬</div>
<div class="node-title">{{ data.label || '消息' }}</div>
</div>
<div v-if="data.description" class="node-description">
{{ data.description }}
</div>
<div v-if="data.config?.message" class="node-preview">
<div class="preview-label">消息:</div>
<div class="preview-content">
{{ data.config.message.substring(0, 100) }}{{ data.config.message.length > 100 ? '...' : '' }}
</div>
</div>
</div>
<Handle
id="output"
type="source"
:position="Position.Right"
class="node-handle"
/>
</div>
</template>
<style scoped>
.message-node {
position: relative;
background: var(--background-color);
border: 2px solid #3B82F6;
border-radius: var(--radius-lg);
padding: var(--space-md);
min-width: 200px;
max-width: 300px;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
.message-node:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.node-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.node-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.node-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-sm);
}
.node-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.3;
}
.node-preview {
background: var(--background-soft);
border-radius: var(--radius-sm);
padding: var(--space-sm);
border: 1px solid var(--border-color);
}
.preview-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 2px;
}
.preview-content {
font-size: 0.7rem;
color: var(--text-color);
line-height: 1.3;
}
.node-handle {
background: #3B82F6;
border: 2px solid var(--background-color);
width: 12px;
height: 12px;
}
.node-handle:hover {
background: #2563EB;
transform: scale(1.2);
}
</style>
@@ -0,0 +1,175 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
interface MessageNodeData {
message_text: string
show_typing: boolean
}
const props = defineProps<{
modelValue: MessageNodeData
validationErrors: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: MessageNodeData): void
(e: 'validate-field', field: string): void
}>()
const formData = computed({
get: () => props.modelValue,
set: (value: MessageNodeData) => emit('update:model-value', value)
})
// Update form data
const updateFormData = (field: keyof MessageNodeData, value: any) => {
formData.value = {
...formData.value,
[field]: value
}
emit('validate-field', field)
}
</script>
<template>
<div class="message-node-config">
<div class="form-group">
<label for="message-text">消息内容 *</label>
<textarea
id="message-text"
:value="formData.message_text"
@input="updateFormData('message_text', ($event.target as HTMLTextAreaElement).value)"
@blur="$emit('validate-field', 'message_text')"
class="form-textarea"
:class="{ 'error': validationErrors.message_text }"
placeholder="输入要发送给用户的消息"
rows="4"
required
></textarea>
<div v-if="validationErrors.message_text" class="error-message">
{{ validationErrors.message_text }}
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input
type="checkbox"
:checked="formData.show_typing"
@change="updateFormData('show_typing', ($event.target as HTMLInputElement).checked)"
class="form-checkbox"
/>
<span>显示输入中提示</span>
</label>
</div>
</div>
</div>
</template>
<style scoped>
.message-node-config {
width: 100%;
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
resize: vertical;
min-height: 70px;
}
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-textarea.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-textarea.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.checkbox-group {
margin-top: 8px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: normal;
margin-bottom: 0;
padding: 4px 0;
}
.form-checkbox {
width: 16px;
height: 16px;
margin: 0;
cursor: pointer;
flex-shrink: 0;
}
.checkbox-label span {
font-size: 0.85rem;
color: var(--text-color);
line-height: 1.4;
user-select: none;
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
</style>
@@ -0,0 +1,223 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
interface UserInputNodeData {
prompt_message: string
confirmation_message: string
}
const props = defineProps<{
modelValue: UserInputNodeData
validationErrors: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: UserInputNodeData): void
(e: 'validateField', field: string): void
}>()
// Computed properties for two-way binding
const promptMessage = computed({
get: () => props.modelValue.prompt_message,
set: (value: string) => {
emit('update:modelValue', { ...props.modelValue, prompt_message: value })
emit('validateField', 'prompt_message')
}
})
const confirmationMessage = computed({
get: () => props.modelValue.confirmation_message,
set: (value: string) => {
emit('update:modelValue', { ...props.modelValue, confirmation_message: value })
emit('validateField', 'confirmation_message')
}
})
</script>
<template>
<div class="user-input-config">
<div class="form-group">
<label for="prompt-message">提示消息</label>
<textarea
id="prompt-message"
v-model="promptMessage"
class="form-textarea"
:class="{ 'error': validationErrors.prompt_message }"
placeholder="输入提示用户填写内容的消息(可选)"
rows="3"
@blur="$emit('validateField', 'prompt_message')"
@input="$emit('validateField', 'prompt_message')"
></textarea>
<div v-if="validationErrors.prompt_message" class="error-message">
{{ validationErrors.prompt_message }}
</div>
<div class="help-text">
用于提示用户填写内容的消息,可选。留空则不显示消息。
</div>
</div>
<div class="form-group">
<label for="confirmation-message">确认消息</label>
<textarea
id="confirmation-message"
v-model="confirmationMessage"
class="form-textarea"
placeholder="用户提交内容后显示的消息(可选,例如“感谢您的填写!”)"
rows="2"
></textarea>
<div class="help-text">
用户提交内容后显示的消息,可选。留空则直接进入下一个节点。
</div>
</div>
<div class="info-section">
<div class="info-header">
<svg class="info-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9,9h0a3,3,0,0,1,6,0c0,2-3,3-3,3"></path>
<path d="M12,17h0"></path>
</svg>
<span>用户输入节点行为</span>
</div>
<div class="info-content">
<ul>
<li>工作流会在此节点暂停,等待用户输入</li>
<li>提示消息会展示给用户</li>
<li>用户输入后,工作流继续执行下一个节点</li>
<li>后续节点可通过变量引用用户输入</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
.user-input-config {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.form-group label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
}
.form-textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.form-textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-textarea.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-textarea.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
.help-text {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.3;
}
.info-section {
margin-top: var(--space-sm);
padding: var(--space-sm);
background: var(--background-soft);
border-radius: var(--radius-md);
border: 1px solid var(--border-color);
}
.info-header {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
}
.info-icon {
width: 16px;
height: 16px;
color: var(--primary-color);
flex-shrink: 0;
}
.info-content {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
}
.info-content ul {
margin: 0;
padding-left: var(--space-md);
list-style-type: disc;
}
.info-content li {
margin-bottom: var(--space-xs);
}
.info-content li:last-child {
margin-bottom: 0;
}
</style>
@@ -0,0 +1,143 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { Node } from '@vue-flow/core'
defineProps<{
data: Node['data']
}>()
</script>
<template>
<div class="wait-node">
<Handle
id="input"
type="target"
:position="Position.Left"
class="node-handle"
/>
<div class="node-content">
<div class="node-header">
<div class="node-icon">⏱️</div>
<div class="node-title">{{ data.label || '等待' }}</div>
</div>
<div v-if="data.description" class="node-description">
{{ data.description }}
</div>
<div v-if="data.config?.duration" class="node-preview">
<div class="preview-label">时长:</div>
<div class="preview-content">{{ data.config.duration }} {{ data.config.unit || '秒' }}</div>
</div>
<div v-if="data.config?.waitType" class="node-preview">
<div class="preview-label">等待类型:</div>
<div class="preview-content">{{ data.config.waitType }}</div>
</div>
</div>
<Handle
id="output"
type="source"
:position="Position.Right"
class="node-handle"
/>
</div>
</template>
<style scoped>
.wait-node {
position: relative;
background: var(--background-color);
border: 2px solid #6B7280;
border-radius: var(--radius-lg);
padding: var(--space-md);
min-width: 200px;
max-width: 300px;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
.wait-node:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.node-content {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.node-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.node-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.node-title {
font-weight: 600;
color: var(--text-color);
font-size: var(--text-sm);
}
.node-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.3;
}
.node-preview {
background: var(--background-soft);
border-radius: var(--radius-sm);
padding: var(--space-sm);
border: 1px solid var(--border-color);
}
.preview-label {
font-size: 0.7rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 2px;
}
.preview-content {
font-size: 0.7rem;
color: var(--text-color);
line-height: 1.3;
}
.node-handle {
background: #6B7280;
border: 2px solid var(--background-color);
width: 12px;
height: 12px;
}
.node-handle:hover {
background: #4B5563;
transform: scale(1.2);
}
</style>
@@ -0,0 +1,155 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
interface WaitNodeData {
wait_duration: number
wait_unit: string
}
const props = defineProps<{
modelValue: WaitNodeData
validationErrors: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'update:model-value', value: WaitNodeData): void
(e: 'validate-field', field: string): void
}>()
const formData = computed({
get: () => props.modelValue,
set: (value: WaitNodeData) => emit('update:model-value', value)
})
// Update form data
const updateFormData = (field: keyof WaitNodeData, value: any) => {
formData.value = {
...formData.value,
[field]: value
}
emit('validate-field', field)
}
</script>
<template>
<div class="wait-node-config">
<div class="form-group">
<label for="wait-duration">时长 *</label>
<input
id="wait-duration"
:value="formData.wait_duration"
@input="updateFormData('wait_duration', parseInt(($event.target as HTMLInputElement).value) || 1)"
@blur="$emit('validate-field', 'wait_duration')"
type="number"
class="form-input"
:class="{ 'error': validationErrors.wait_duration }"
min="1"
placeholder="5"
required
/>
<div v-if="validationErrors.wait_duration" class="error-message">
{{ validationErrors.wait_duration }}
</div>
</div>
<div class="form-group">
<label for="wait-unit">时间单位 *</label>
<select
id="wait-unit"
:value="formData.wait_unit"
@change="updateFormData('wait_unit', ($event.target as HTMLSelectElement).value)"
@blur="$emit('validate-field', 'wait_unit')"
class="form-select"
:class="{ 'error': validationErrors.wait_unit }"
required
>
<option value="seconds">秒</option>
<option value="minutes">分钟</option>
<option value="hours">小时</option>
</select>
<div v-if="validationErrors.wait_unit" class="error-message">
{{ validationErrors.wait_unit }}
</div>
</div>
</div>
</template>
<style scoped>
.wait-node-config {
width: 100%;
}
.form-group {
margin-bottom: var(--space-sm);
}
.form-group label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.form-input,
.form-select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--background-color);
color: var(--text-color);
font-size: 0.85rem;
transition: border-color 0.2s ease;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(201, 242, 78, 0.15);
}
.form-input.error,
.form-select.error {
border-color: var(--error-color);
background-color: rgba(239, 68, 68, 0.05);
}
.form-input.error:focus,
.form-select.error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1);
}
.error-message {
color: var(--error-color);
font-size: 0.75rem;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
.error-message::before {
content: "⚠";
font-size: 0.8rem;
}
</style>
@@ -0,0 +1,28 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { App } from 'vue'
// TODO: Import node editors when they are created
// import MessageNodeEditor from './MessageNodeEditor.vue'
// import LlmNodeEditor from './LlmNodeEditor.vue'
export default {
install(app: App) {
// TODO: Register node editors when they are created
// app.component('message-node-editor', MessageNodeEditor)
// app.component('llm-node-editor', LlmNodeEditor)
}
}
+496
View File
@@ -0,0 +1,496 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import { faqService } from '@/services/faq'
import { knowledgeService } from '@/services/knowledge'
import type { FaqGenerationJob, FaqImportMode, FaqItem, FaqStatus, GenerateEstimate, HelpCenterSettings } from '@/types/faq'
export type WorkspacePhase = 'loading' | 'empty' | 'generating' | 'populated'
// Fast tick while a job is running; relaxed tick while a domain/SSL state is
// pending. Idle = no requests at all.
const ACTIVE_POLL_MS = 3000
const IDLE_POLL_MS = 10000
export function useFaqWorkspace(organizationId: () => string | undefined) {
const faqs = ref<FaqItem[]>([])
const job = ref<FaqGenerationJob | null>(null)
const settings = ref<HelpCenterSettings | null>(null)
const estimate = ref<GenerateEstimate | null>(null)
const sourceCount = ref(0)
const pageCount = ref(0)
const isLoading = ref(false)
const loadedOnce = ref(false)
// Multi-select state for bulk publish/unpublish/delete.
const selectedIds = ref<Set<string>>(new Set())
const selectionActive = computed(() => selectedIds.value.size > 0)
function toggleSelect(id: string): void {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function setSelected(items: FaqItem[], on: boolean): void {
const next = new Set(selectedIds.value)
for (const item of items) {
if (on) next.add(item.id)
else next.delete(item.id)
}
selectedIds.value = next
}
function clearSelection(): void {
selectedIds.value = new Set()
}
async function bulkSetStatus(status: FaqStatus): Promise<void> {
const ids = [...selectedIds.value]
if (!ids.length) return
try {
const updated = await faqService.setStatus(ids, status)
faqs.value = faqs.value.map((f) => (selectedIds.value.has(f.id) ? { ...f, status } : f))
clearSelection()
toast.success(`${updated} 条 FAQ ${status === 'published' ? '已发布' : '已移至草稿'}`)
} catch (error: any) {
toast.error(error.message)
// A batch may have partially applied server-side (>200 selection splits
// into requests) — refetch so the list matches the backend.
await refresh()
}
}
async function bulkDelete(): Promise<void> {
const ids = [...selectedIds.value]
if (!ids.length) return
try {
const deleted = await faqService.bulkDelete(ids)
faqs.value = faqs.value.filter((f) => !selectedIds.value.has(f.id))
clearSelection()
toast.success(`${deleted} 条 FAQ 已删除`)
// Deleting a source's FAQs makes it eligible for generation again — the
// Generate button's new-source count must not stay stale/disabled.
void fetchEstimate()
} catch (error: any) {
toast.error(error.message)
await refresh() // partial batch may have deleted server-side; resync
}
}
// Inline edit state (one card at a time).
const editingId = ref<string | null>(null)
const isNewFaq = ref(false)
const draftQuestion = ref('')
const draftAnswer = ref('')
// Topic (stored as the FAQ's free-form category). Empty on create means the
// server default; empty on edit means "keep the current topic".
const draftCategory = ref('')
// Per-article SEO overrides. Empty means "derive it" — the server normalizes
// a hand-typed slug and treats blanks as cleared, so no client-side slugify.
const draftSlug = ref('')
const draftUrlPath = ref('')
const draftMetaTitle = ref('')
const draftMetaDescription = ref('')
const isSaving = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
let lastPollAt = 0
const isJobActive = computed(
() => job.value?.status === 'pending' || job.value?.status === 'processing',
)
const phase = computed<WorkspacePhase>(() => {
if (isLoading.value && !loadedOnce.value) return 'loading'
// Regenerating with existing FAQs keeps the list visible — only the
// generate bar flips; the full progress card is for first-run only.
if (isJobActive.value && faqs.value.length === 0) return 'generating'
return faqs.value.length > 0 ? 'populated' : 'empty'
})
const barPhase = computed<'idle' | 'generating' | 'ready'>(() => {
if (isJobActive.value) return 'generating'
return faqs.value.length > 0 ? 'ready' : 'idle'
})
const publishedCount = computed(() => faqs.value.filter((f) => f.status === 'published').length)
// ---- search + filter + collapse (all client-side over the loaded set) ----
const searchQuery = ref('')
const categoryFilter = ref<string | null>(null)
const statusFilter = ref<'all' | FaqStatus>('all')
const collapsedCategories = ref<Set<string>>(new Set())
const categories = computed(() =>
[...new Set(faqs.value.map((f) => f.category))].sort((a, b) => a.localeCompare(b)),
)
const hasActiveFilters = computed(
() =>
searchQuery.value.trim() !== '' ||
categoryFilter.value !== null ||
statusFilter.value !== 'all',
)
const filteredFaqs = computed(() => {
// Tokenized: every term must appear somewhere (question/answer/category),
// so "close my" matches "How to close my account".
const terms = searchQuery.value.trim().toLowerCase().split(/\s+/).filter(Boolean)
return faqs.value.filter((f) => {
if (categoryFilter.value && f.category !== categoryFilter.value) return false
if (statusFilter.value !== 'all' && f.status !== statusFilter.value) return false
if (terms.length) {
const haystack = `${f.question} ${f.answer} ${f.category}`.toLowerCase()
if (!terms.every((t) => haystack.includes(t))) return false
}
return true
})
})
const filteredCount = computed(() => filteredFaqs.value.length)
const groupedFaqs = computed(() => {
const groups = new Map<string, FaqItem[]>()
for (const faq of filteredFaqs.value) {
const list = groups.get(faq.category) || []
list.push(faq)
groups.set(faq.category, list)
}
return groups
})
function resetFilters(): void {
searchQuery.value = ''
categoryFilter.value = null
statusFilter.value = 'all'
}
function toggleCategory(category: string): void {
const next = new Set(collapsedCategories.value)
if (next.has(category)) next.delete(category)
else next.add(category)
collapsedCategories.value = next
}
// While filtering/searching, always show matches regardless of collapse state.
function isCategoryOpen(category: string): boolean {
return hasActiveFilters.value || !collapsedCategories.value.has(category)
}
const domainPending = computed(() => {
const domain = settings.value?.domain
if (!domain?.custom_domain) return false
return domain.domain_status !== 'verified' || domain.ssl_status === 'pending'
})
// Bounded safety cap; a curated FAQ list should never approach this.
const MAX_FAQ_PAGES = 10
async function fetchFaqs(): Promise<void> {
const all: FaqItem[] = []
let page = 1
for (;;) {
const response = await faqService.getFaqs({ page })
all.push(...response.faqs)
if (page >= response.pagination.total_pages || page >= MAX_FAQ_PAGES) break
page += 1
}
faqs.value = all
}
async function fetchJob(): Promise<void> {
job.value = await faqService.getJob(true)
}
async function fetchSettings(): Promise<void> {
settings.value = await faqService.getSettings()
}
async function fetchEstimate(includePages = false): Promise<void> {
// Background fetches (page load, deletes, job completion) skip the
// per-source page scan — the button label only needs new_sources. The
// confirm dialog passes includePages=true for the full call estimate.
// Non-fatal: locked plans 403 here; the generate button then just says
// "Generate" without the new-source count.
try {
estimate.value = await faqService.getGenerateEstimate(includePages)
} catch {
estimate.value = null
}
}
async function fetchCounts(): Promise<void> {
const orgId = organizationId()
if (!orgId) return
const response = await knowledgeService.getKnowledgeByOrganization(orgId, 1, 100)
const items = response.knowledge || []
sourceCount.value = response.pagination?.total ?? response.pagination?.total_count ?? items.length
pageCount.value = items.reduce(
(sum: number, item: { pages?: unknown[] }) => sum + (item.pages?.length || 1),
0,
)
}
async function refresh(): Promise<void> {
isLoading.value = true
clearSelection()
try {
await Promise.all([fetchFaqs(), fetchJob(), fetchSettings(), fetchCounts(), fetchEstimate()])
loadedOnce.value = true
} catch (error: any) {
toast.error(error.message)
} finally {
isLoading.value = false
}
}
async function pollTick(): Promise<void> {
const interval = isJobActive.value ? ACTIVE_POLL_MS : IDLE_POLL_MS
if (!isJobActive.value && !domainPending.value) return
const now = Date.now()
if (now - lastPollAt < interval - 100) return
lastPollAt = now
try {
if (isJobActive.value) {
const wasActive = job.value?.id
await fetchJob()
const stillActive = job.value?.status === 'pending' || job.value?.status === 'processing'
if (wasActive && !stillActive) {
await Promise.all([fetchFaqs(), fetchSettings(), fetchEstimate()])
const finished = await faqService.getJob(false)
if (finished?.status === 'failed') {
toast.error(finished.error || 'FAQ 生成失败')
} else if (finished) {
toast.success(
finished.faqs_created
? `${finished.faqs_created} 条草稿 FAQ 待审核`
: '没有发现新的 FAQ,现有 FAQ 已覆盖这些内容',
)
}
}
} else if (domainPending.value) {
await fetchSettings()
}
} catch {
// Transient polling errors are silent; the next tick retries.
}
}
function startPolling(): void {
stopPolling()
pollTimer = setInterval(pollTick, ACTIVE_POLL_MS)
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function startGeneration(knowledgeIds?: number[]): Promise<boolean> {
try {
job.value = await faqService.startGeneration(knowledgeIds)
return true
} catch (error: any) {
toast.error(error.message)
return false
}
}
async function submitImport(
url: string,
mode: FaqImportMode = 'qa',
preserveUrls = false,
): Promise<boolean> {
try {
job.value = await faqService.importFaq(url, mode, preserveUrls)
toast.success('导入已开始,完成后会生成草稿')
return true
} catch (error: any) {
toast.error(error.message)
return false
}
}
async function submitPdfImport(file: File): Promise<boolean> {
try {
job.value = await faqService.importPdf(file)
toast.success('导入已开始,完成后会生成草稿')
return true
} catch (error: any) {
toast.error(error.message)
return false
}
}
async function togglePublish(faq: FaqItem): Promise<void> {
const nextStatus = faq.status === 'published' ? 'draft' : 'published'
const previous = faq.status
faq.status = nextStatus // optimistic
try {
await faqService.setStatus([faq.id], nextStatus)
} catch (error: any) {
faq.status = previous
toast.error(error.message)
}
}
function setSeoDraft(faq: FaqItem | null): void {
draftSlug.value = faq?.slug || ''
draftUrlPath.value = faq?.url_path || ''
draftMetaTitle.value = faq?.meta_title || ''
draftMetaDescription.value = faq?.meta_description || ''
}
function startEdit(faq: FaqItem): void {
editingId.value = faq.id
isNewFaq.value = false
draftQuestion.value = faq.question
draftAnswer.value = faq.answer
draftCategory.value = faq.category
setSeoDraft(faq)
}
function startAdd(): void {
editingId.value = 'new'
isNewFaq.value = true
draftQuestion.value = ''
draftAnswer.value = ''
draftCategory.value = ''
setSeoDraft(null)
}
function cancelEdit(): void {
editingId.value = null
isNewFaq.value = false
draftQuestion.value = ''
draftAnswer.value = ''
draftCategory.value = ''
setSeoDraft(null)
}
async function saveEdit(): Promise<void> {
const question = draftQuestion.value.trim()
const answer = draftAnswer.value.trim()
if (!question || !answer) {
toast.error('问题和答案均为必填')
return
}
// Blank SEO fields are sent through so clearing one in the UI clears it
// server-side, restoring the derived title/description (and, for the slug,
// keeping whatever is already assigned).
const seo = {
slug: draftSlug.value.trim(),
url_path: draftUrlPath.value.trim(),
meta_title: draftMetaTitle.value.trim(),
meta_description: draftMetaDescription.value.trim(),
}
// The server rejects a blank category, so only send one when set: on
// create a blank falls back to the server default, on edit it keeps the
// FAQ's current topic.
const category = draftCategory.value.trim()
const withCategory = category ? { category } : {}
isSaving.value = true
try {
if (isNewFaq.value) {
const created = await faqService.createFaq({ question, answer, ...withCategory, ...seo })
faqs.value = [...faqs.value, created]
} else if (editingId.value) {
const updated = await faqService.updateFaq(editingId.value, { question, answer, ...withCategory, ...seo })
faqs.value = faqs.value.map((f) => (f.id === updated.id ? updated : f))
}
cancelEdit()
} catch (error: any) {
toast.error(error.message)
} finally {
isSaving.value = false
}
}
async function deleteFaq(faq: FaqItem): Promise<void> {
try {
await faqService.deleteFaq(faq.id)
faqs.value = faqs.value.filter((f) => f.id !== faq.id)
if (editingId.value === faq.id) cancelEdit()
// Keep the Generate button's new-source count fresh (see bulkDelete).
void fetchEstimate()
} catch (error: any) {
toast.error(error.message)
}
}
return {
faqs,
job,
settings,
estimate,
fetchEstimate,
sourceCount,
pageCount,
isLoading,
phase,
barPhase,
publishedCount,
groupedFaqs,
searchQuery,
categoryFilter,
statusFilter,
categories,
hasActiveFilters,
filteredCount,
resetFilters,
toggleCategory,
isCategoryOpen,
isJobActive,
selectedIds,
selectionActive,
toggleSelect,
setSelected,
clearSelection,
bulkSetStatus,
bulkDelete,
editingId,
isNewFaq,
draftQuestion,
draftAnswer,
draftCategory,
draftSlug,
draftUrlPath,
draftMetaTitle,
draftMetaDescription,
isSaving,
refresh,
fetchSettings,
startPolling,
stopPolling,
pollTick,
startGeneration,
submitImport,
submitPdfImport,
togglePublish,
startEdit,
startAdd,
cancelEdit,
saveEdit,
deleteFaq,
}
}
@@ -0,0 +1,205 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { computed, ref, watch, type Ref } from 'vue'
import { toast } from 'vue-sonner'
import { faqService } from '@/services/faq'
import type { HelpCenterSettings, HelpCenterSettingsUpdate } from '@/types/faq'
export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
const AUTOSAVE_DEBOUNCE_MS = 800
const MAX_LOGO_BYTES = 2 * 1024 * 1024
const MAX_FAVICON_BYTES = 1 * 1024 * 1024
/** Appearance/settings editing with debounced autosave — the design has no
* Save button ("changes apply instantly to your published help center"). */
export function useHelpCenterSettings(settings: Ref<HelpCenterSettings | null>) {
const saveState = ref<SaveState>('idle')
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let saveInFlight = false
let trailingSave: HelpCenterSettingsUpdate | null = null
let suppressWatch = false
const brandColor = computed(() => settings.value?.brand_color || '#4338CA')
function applyResponse(updated: HelpCenterSettings): void {
suppressWatch = true
settings.value = updated
// Release after the watcher has seen (and ignored) this assignment.
setTimeout(() => {
suppressWatch = false
}, 0)
}
async function save(payload: HelpCenterSettingsUpdate): Promise<void> {
if (saveInFlight) {
trailingSave = { ...(trailingSave || {}), ...payload }
return
}
saveInFlight = true
saveState.value = 'saving'
try {
applyResponse(await faqService.updateSettings(payload))
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
} finally {
saveInFlight = false
if (trailingSave) {
const queued = trailingSave
trailingSave = null
void save(queued)
}
}
}
/** Debounced path for text inputs (links, CTA, labels). */
function queueSave(payload: HelpCenterSettingsUpdate): void {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => void save(payload), AUTOSAVE_DEBOUNCE_MS)
}
/** Immediate path for discrete actions (swatch click, toggles, selects). */
async function saveNow(payload: HelpCenterSettingsUpdate): Promise<void> {
if (debounceTimer) clearTimeout(debounceTimer)
await save(payload)
}
// Deep-watch branding fields the inputs mutate in place (header_links rows).
watch(
() => settings.value?.header_links,
(links, previous) => {
if (suppressWatch || links === undefined || previous === undefined) return
queueSave({ header_links: links })
},
{ deep: true },
)
async function uploadLogo(file: File): Promise<void> {
if (file.size > MAX_LOGO_BYTES) {
toast.error('Logo 不能超过 2 MB')
return
}
saveState.value = 'saving'
try {
applyResponse(await faqService.uploadLogo(file))
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
}
}
async function removeLogo(): Promise<void> {
saveState.value = 'saving'
try {
applyResponse(await faqService.removeLogo())
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
}
}
async function uploadFavicon(file: File): Promise<void> {
if (file.size > MAX_FAVICON_BYTES) {
toast.error('网站图标不能超过 1 MB')
return
}
saveState.value = 'saving'
try {
applyResponse(await faqService.uploadFavicon(file))
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
}
}
async function removeFavicon(): Promise<void> {
saveState.value = 'saving'
try {
applyResponse(await faqService.removeFavicon())
saveState.value = 'saved'
} catch (error: any) {
saveState.value = 'error'
toast.error(error.message)
}
}
// Domain lifecycle (explicit Verify button — never autosaved).
const domainBusy = ref(false)
async function setDomain(domain: string): Promise<void> {
domainBusy.value = true
try {
const result = await faqService.setDomain(domain)
if (settings.value) settings.value = { ...settings.value, domain: result }
} catch (error: any) {
toast.error(error.message)
} finally {
domainBusy.value = false
}
}
async function verifyDomain(): Promise<void> {
domainBusy.value = true
try {
const result = await faqService.verifyDomain()
if (settings.value) settings.value = { ...settings.value, domain: result }
if (result.domain_status === 'verified') {
toast.success('域名已验证')
} else {
toast.info('记录尚未生效,DNS 变更可能需要几分钟才能传播')
}
} catch (error: any) {
toast.error(error.message)
} finally {
domainBusy.value = false
}
}
async function removeDomain(): Promise<void> {
domainBusy.value = true
try {
const result = await faqService.removeDomain()
if (settings.value) settings.value = { ...settings.value, domain: result }
} catch (error: any) {
toast.error(error.message)
} finally {
domainBusy.value = false
}
}
return {
saveState,
brandColor,
queueSave,
saveNow,
uploadLogo,
removeLogo,
uploadFavicon,
removeFavicon,
domainBusy,
setDomain,
verifyDomain,
removeDomain,
}
}
@@ -0,0 +1,260 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import {
checkJiraConnection,
getJiraProjects,
getJiraIssueTypes,
saveAgentJiraConfig,
getAgentJiraConfig
} from '@/services/jira'
export function useJiraIntegration(agentId: string) {
// Jira integration state
const jiraConnected = ref(false)
const jiraLoading = ref(false)
const createTicketEnabled = ref(false)
const jiraProjects = ref<any[]>([])
const jiraIssueTypes = ref<any[]>([])
const selectedProject = ref<string>('')
const selectedIssueType = ref<string>('')
const loadingProjects = ref(false)
const loadingIssueTypes = ref(false)
/**
* Check if Jira is connected
*/
const checkJiraStatus = async () => {
try {
jiraLoading.value = true
const status = await checkJiraConnection()
jiraConnected.value = status.connected
// If Jira is connected, fetch projects
if (jiraConnected.value && createTicketEnabled.value) {
await fetchJiraProjects()
}
} catch (error) {
console.error('Failed to check Jira status:', error)
} finally {
jiraLoading.value = false
}
}
/**
* Fetch Jira projects
*/
const fetchJiraProjects = async () => {
if (!jiraConnected.value) return
try {
loadingProjects.value = true
const projects = await getJiraProjects()
jiraProjects.value = projects
} catch (error) {
console.error('Failed to fetch Jira projects:', error)
toast.error('加载 Jira 项目失败')
} finally {
loadingProjects.value = false
}
}
/**
* Fetch Jira issue types for a project
*/
const fetchJiraIssueTypes = async (projectKey: string) => {
if (!projectKey) {
jiraIssueTypes.value = []
return
}
try {
loadingIssueTypes.value = true
const issueTypes = await getJiraIssueTypes(projectKey)
jiraIssueTypes.value = issueTypes
} catch (error) {
console.error('Failed to fetch Jira issue types:', error)
toast.error('加载 Jira 问题类型失败')
} finally {
loadingIssueTypes.value = false
}
}
/**
* Toggle Jira ticket creation
*/
const toggleCreateTicket = async () => {
// If enabling and Jira is not connected, show error
if (!createTicketEnabled.value && !jiraConnected.value) {
toast.error('无法开启工单创建:Jira 未连接', {
description: '请先在集成设置中连接 Jira。',
duration: 5000
})
return
}
try {
// Toggle the create ticket setting
const newValue = !createTicketEnabled.value
// If disabling, just save the config with enabled=false
if (!newValue) {
await saveAgentJiraConfig(agentId, { enabled: false })
createTicketEnabled.value = false
toast.success('工单创建已关闭')
return
}
// If enabling but no project/issue type selected yet, just update the UI state
// The actual save will happen when the user selects a project and issue type
if (newValue && (!selectedProject.value || !selectedIssueType.value)) {
createTicketEnabled.value = true
// Fetch projects if not already loaded
if (jiraProjects.value.length === 0) {
await fetchJiraProjects()
}
return
}
// If enabling and project/issue type are selected, save the config
await saveAgentJiraConfig(agentId, {
enabled: true,
projectKey: selectedProject.value,
issueTypeId: selectedIssueType.value
})
createTicketEnabled.value = true
toast.success('工单创建已开启')
} catch (error) {
console.error('Failed to toggle ticket creation:', error)
toast.error('更新工单创建设置失败')
}
}
/**
* Save Jira configuration
*/
const saveJiraConfig = async (projectKey?: string, issueTypeId?: string) => {
// Use provided values or fall back to the state values
const projectToUse = projectKey || selectedProject.value
const issueTypeToUse = issueTypeId || selectedIssueType.value
if (!projectToUse || !issueTypeToUse) {
toast.error('请选择项目和问题类型')
return
}
try {
await saveAgentJiraConfig(agentId, {
enabled: true,
projectKey: projectToUse,
issueTypeId: issueTypeToUse
})
// Update the local state to match what was saved
selectedProject.value = projectToUse
selectedIssueType.value = issueTypeToUse
toast.success('Jira 配置已保存')
} catch (error) {
console.error('Failed to save Jira config:', error)
toast.error('保存 Jira 配置失败')
}
}
/**
* Fetch agent's Jira configuration
*/
const fetchAgentJiraConfig = async () => {
try {
const config = await getAgentJiraConfig(agentId)
createTicketEnabled.value = config.enabled
selectedProject.value = config.projectKey || ''
selectedIssueType.value = config.issueTypeId || ''
// If there's an existing configuration, fetch the projects and issue types
if (config.enabled && config.projectKey) {
// First check if Jira is connected
const status = await checkJiraConnection()
jiraConnected.value = status.connected
if (jiraConnected.value) {
// Fetch projects
loadingProjects.value = true
try {
const projects = await getJiraProjects()
jiraProjects.value = projects
// If project is selected, fetch issue types
if (selectedProject.value) {
await fetchJiraIssueTypes(selectedProject.value)
}
} catch (error) {
console.error('Failed to fetch Jira projects:', error)
toast.error('加载 Jira 项目失败')
} finally {
loadingProjects.value = false
}
}
}
} catch (error) {
console.error('Failed to fetch agent Jira config:', error)
}
}
/**
* Handle project change
*/
const handleProjectChange = async (projectKey: string) => {
if (projectKey !== selectedProject.value) {
selectedProject.value = projectKey
selectedIssueType.value = '' // Reset issue type when project changes
await fetchJiraIssueTypes(projectKey)
}
}
/**
* Handle issue type change
*/
const handleIssueTypeChange = (issueTypeId: string) => {
selectedIssueType.value = issueTypeId
}
return {
// State
jiraConnected,
jiraLoading,
createTicketEnabled,
jiraProjects,
jiraIssueTypes,
selectedProject,
selectedIssueType,
loadingProjects,
loadingIssueTypes,
// Methods
checkJiraStatus,
fetchJiraProjects,
fetchJiraIssueTypes,
toggleCreateTicket,
saveJiraConfig,
fetchAgentJiraConfig,
handleProjectChange,
handleIssueTypeChange
}
}
+254
View File
@@ -0,0 +1,254 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ref, computed } from 'vue'
import { toast } from 'vue-sonner'
import {
checkJiraConnection,
getJiraProjects,
getJiraIssueTypes,
getJiraPriorities,
createJiraTicket,
checkPriorityAvailability
} from '@/services/jira'
export function useJiraTicket() {
// State
const jiraConnected = ref(false)
const jiraLoading = ref(false)
const showTicketModal = ref(false)
const jiraProjects = ref<any[]>([])
const jiraIssueTypes = ref<any[]>([])
const jiraPriorities = ref<any[]>([])
const selectedProject = ref<string>('')
const selectedIssueType = ref<string>('')
const selectedPriority = ref<string>('')
const ticketSummary = ref<string>('')
const ticketDescription = ref<string>('')
const loadingProjects = ref(false)
const loadingIssueTypes = ref(false)
const loadingPriorities = ref(false)
const creatingTicket = ref(false)
const createdTicketKey = ref<string | null>(null)
const priorityAvailable = ref(false)
const checkingPriorityAvailability = ref(false)
// Check if Jira is connected
const checkJiraStatus = async () => {
jiraLoading.value = true
try {
const response = await checkJiraConnection()
jiraConnected.value = response.connected
if (jiraConnected.value) {
await fetchJiraProjects()
await fetchJiraPriorities()
}
} catch (error) {
console.error('Error checking Jira status:', error)
jiraConnected.value = false
} finally {
jiraLoading.value = false
}
}
// Fetch Jira projects
const fetchJiraProjects = async () => {
if (!jiraConnected.value) return
loadingProjects.value = true
try {
const projects = await getJiraProjects()
jiraProjects.value = projects
} catch (error) {
console.error('Error fetching Jira projects:', error)
toast.error('加载 Jira 项目失败')
} finally {
loadingProjects.value = false
}
}
// Fetch Jira issue types for a project
const fetchJiraIssueTypes = async (projectKey: string) => {
if (!projectKey) return
loadingIssueTypes.value = true
try {
const issueTypes = await getJiraIssueTypes(projectKey)
jiraIssueTypes.value = issueTypes
} catch (error) {
console.error('Error fetching Jira issue types:', error)
toast.error('加载问题类型失败')
} finally {
loadingIssueTypes.value = false
}
}
// Fetch Jira priorities
const fetchJiraPriorities = async () => {
if (!jiraConnected.value) return
loadingPriorities.value = true
try {
const priorities = await getJiraPriorities()
jiraPriorities.value = priorities
} catch (error) {
console.error('Error fetching Jira priorities:', error)
toast.error('加载优先级失败')
} finally {
loadingPriorities.value = false
}
}
// Handle project change
const handleProjectChange = async (projectKey: string) => {
selectedProject.value = projectKey
selectedIssueType.value = ''
selectedPriority.value = ''
priorityAvailable.value = false
if (projectKey) {
await fetchJiraIssueTypes(projectKey)
} else {
jiraIssueTypes.value = []
}
}
// Handle issue type change
const handleIssueTypeChange = async (issueTypeId: string) => {
selectedIssueType.value = issueTypeId
selectedPriority.value = ''
if (selectedProject.value && issueTypeId) {
await checkPriorityField(selectedProject.value, issueTypeId)
} else {
priorityAvailable.value = false
}
}
// Check if priority field is available
const checkPriorityField = async (projectKey: string, issueTypeId: string) => {
checkingPriorityAvailability.value = true
try {
priorityAvailable.value = await checkPriorityAvailability(projectKey, issueTypeId)
// If priority is available, fetch priorities
if (priorityAvailable.value) {
await fetchJiraPriorities()
}
} catch (error) {
console.error('Error checking priority availability:', error)
priorityAvailable.value = false
} finally {
checkingPriorityAvailability.value = false
}
}
// Open ticket modal
const openTicketModal = async (chatSummary?: string) => {
// Reset form
selectedProject.value = ''
selectedIssueType.value = ''
selectedPriority.value = ''
ticketDescription.value = ''
createdTicketKey.value = null
// Set summary from chat if provided
ticketSummary.value = chatSummary || ''
// Check Jira status and load data
await checkJiraStatus()
// Show modal
showTicketModal.value = true
}
// Close ticket modal
const closeTicketModal = () => {
showTicketModal.value = false
}
// Create Jira ticket
const submitTicket = async (chatId?: string) => {
if (!selectedProject.value || !selectedIssueType.value || !ticketSummary.value) {
toast.error('请填写所有必填项')
return
}
creatingTicket.value = true
try {
const response = await createJiraTicket({
projectKey: selectedProject.value,
issueTypeId: selectedIssueType.value,
summary: ticketSummary.value,
description: ticketDescription.value,
priority: selectedPriority.value,
chatId
})
createdTicketKey.value = response.key
toast.success(`工单 ${response.key} 已创建`)
return response.key
} catch (error) {
console.error('Error creating ticket:', error)
toast.error('创建工单失败')
return null
} finally {
creatingTicket.value = false
}
}
// Check if form is valid
const isFormValid = computed(() => {
return selectedProject.value &&
selectedIssueType.value &&
ticketSummary.value.trim().length > 0
})
return {
// State
jiraConnected,
jiraLoading,
showTicketModal,
jiraProjects,
jiraIssueTypes,
jiraPriorities,
selectedProject,
selectedIssueType,
selectedPriority,
ticketSummary,
ticketDescription,
loadingProjects,
loadingIssueTypes,
loadingPriorities,
creatingTicket,
createdTicketKey,
priorityAvailable,
checkingPriorityAvailability,
isFormValid,
// Methods
checkJiraStatus,
fetchJiraProjects,
fetchJiraIssueTypes,
fetchJiraPriorities,
handleProjectChange,
handleIssueTypeChange,
checkPriorityField,
openTicketModal,
closeTicketModal,
submitTicket
}
}
+382
View File
@@ -0,0 +1,382 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ref, reactive } from 'vue'
import { mcpService } from '@/services/mcp'
import type { MCPTool, MCPToolCreate, MCPToolReferences, MCPToolTestResult, MCPTransportType } from '@/types/mcp'
import { DEFAULT_MCP_TIMEOUT, clampMCPTimeout } from '@/utils/mcp'
import { toast } from 'vue-sonner'
export function useMCPTools(agentId: string) {
// State
const agentMCPTools = ref<MCPTool[]>([])
const availableMCPTools = ref<MCPTool[]>([])
const isLoading = ref(false)
const isLoadingAvailable = ref(false)
const error = ref<string | null>(null)
const showCreateModal = ref(false)
const showLinkModal = ref(false)
const showDeleteConfirm = ref(false)
const deleteTargetId = ref<number | null>(null)
const deleteReferences = ref<MCPToolReferences | null>(null)
// Which tool the form is editing, or null when it is creating one.
const editingToolId = ref<number | null>(null)
// Form state for creating MCP tools
const createForm = reactive<MCPToolCreate>({
name: '',
description: '',
usage_guidance: '',
transport_type: 'stdio' as MCPTransportType,
enabled: true,
command: '',
args: [],
env_vars: {},
url: '',
headers: {},
timeout: DEFAULT_MCP_TIMEOUT,
sse_read_timeout: 60,
terminate_on_close: true
})
// Transport type options
const transportTypes = [
{ value: 'stdio', label: 'STDIO', description: '标准输入/输出通信' },
{ value: 'sse', label: 'Server-Sent Events', description: 'HTTP 流式通信' },
{ value: 'http', label: 'HTTP', description: '请求/响应通信' }
]
// Common MCP tool presets
const mcpPresets = [
{
name: 'File System',
description: 'Access and manage files and directories',
transport_type: 'stdio' as MCPTransportType,
command: 'npx',
args: ['-y','@modelcontextprotocol/server-filesystem'],
env_vars: { ALLOWED_DIRECTORIES: '/path/to/allowed/directory' }
},
{
name: 'Weather',
description: 'Get weather information',
transport_type: 'stdio' as MCPTransportType,
command: 'uvx',
args: ["--from", "git+https://github.com/adhikasp/mcp-weather.git", "mcp-weather"],
env_vars: { ACCUWEATHER_API_KEY: 'your-api-key' }
}
]
// Fetch agent's MCP tools
const fetchAgentMCPTools = async () => {
isLoading.value = true
error.value = null
try {
const response = await mcpService.getAgentMCPTools(agentId)
agentMCPTools.value = response.mcp_tools
} catch (err: any) {
error.value = err.response?.data?.detail || '加载 MCP 工具失败'
console.error('Error fetching agent MCP tools:', err)
} finally {
isLoading.value = false
}
}
// Fetch available MCP tools for linking
const fetchAvailableMCPTools = async () => {
isLoadingAvailable.value = true
try {
const tools = await mcpService.getOrganizationMCPTools(true)
availableMCPTools.value = tools
} catch (err: any) {
console.error('Error fetching available MCP tools:', err)
toast.error('加载可用 MCP 工具失败')
} finally {
isLoadingAvailable.value = false
}
}
// Load an existing tool into the shared form. Secret values arrive masked;
// leaving them alone keeps the stored credential.
const startEdit = (tool: MCPTool) => {
Object.assign(createForm, {
name: tool.name,
description: tool.description || '',
usage_guidance: tool.usage_guidance || '',
transport_type: tool.transport_type,
enabled: tool.enabled,
command: tool.command || '',
args: [...(tool.args || [])],
env_vars: { ...(tool.env_vars || {}) },
url: tool.url || '',
headers: { ...(tool.headers || {}) },
timeout: tool.timeout ?? DEFAULT_MCP_TIMEOUT,
sse_read_timeout: tool.sse_read_timeout ?? 60,
terminate_on_close: tool.terminate_on_close ?? true
})
editingToolId.value = tool.id
showCreateModal.value = true
}
// Create a new MCP tool, or save the one being edited
const saveMCPTool = async () => {
const isEditing = editingToolId.value !== null
try {
const payload = { ...createForm, timeout: clampMCPTimeout(createForm.timeout) }
if (isEditing) {
await mcpService.updateMCPTool(editingToolId.value as number, payload)
} else {
const newTool = await mcpService.createMCPTool(payload)
// Add to agent immediately
await mcpService.addMCPToolToAgent(newTool.id, agentId)
}
// Refresh agent tools
await fetchAgentMCPTools()
// Reset form and close modal
resetCreateForm()
showCreateModal.value = false
editingToolId.value = null
toast.success(isEditing ? 'MCP 工具已更新' : 'MCP 工具已创建并关联')
} catch (err: any) {
const errorMessage = err.response?.data?.detail
|| (isEditing ? '更新 MCP 工具失败' : '创建 MCP 工具失败')
toast.error(errorMessage)
throw err
}
}
// Link existing MCP tool to agent
const linkMCPTool = async (toolId: number) => {
try {
await mcpService.addMCPToolToAgent(toolId, agentId)
await fetchAgentMCPTools()
toast.success('MCP 工具已关联')
} catch (err: any) {
const errorMessage = err.response?.data?.detail || '关联 MCP 工具失败'
toast.error(errorMessage)
}
}
// Unlink MCP tool from agent
const unlinkMCPTool = async (toolId: number) => {
try {
await mcpService.removeMCPToolFromAgent(toolId, agentId)
await fetchAgentMCPTools()
toast.success('MCP 工具已取消关联')
} catch (err: any) {
const errorMessage = err.response?.data?.detail || '取消关联 MCP 工具失败'
toast.error(errorMessage)
}
}
// Delete MCP tool
const deleteMCPTool = async () => {
if (!deleteTargetId.value) return
try {
await mcpService.deleteMCPTool(deleteTargetId.value)
await fetchAgentMCPTools()
cancelDelete()
toast.success('MCP 工具已删除')
} catch (err: any) {
const errorMessage = err.response?.data?.detail || '删除 MCP 工具失败'
toast.error(errorMessage)
}
}
// Connection test results, per tool id. A tool that saves fine can still be
// dead at runtime (missing npx, bad key, unreachable server) — the Test
// button surfaces that instead of a silent 0-tool run.
const testResults = ref<Record<number, MCPToolTestResult>>({})
const testingToolId = ref<number | null>(null)
const testMCPTool = async (toolId: number) => {
testingToolId.value = toolId
try {
testResults.value = { ...testResults.value, [toolId]: await mcpService.testMCPTool(toolId) }
} catch (err: any) {
const errorMessage = err.response?.data?.detail || '测试 MCP 工具失败'
testResults.value = { ...testResults.value, [toolId]: { success: false, functions: [], error: errorMessage } }
} finally {
testingToolId.value = null
}
}
// Apply preset to form
const applyPreset = (preset: typeof mcpPresets[0]) => {
Object.assign(createForm, {
...preset,
// Presets describe themselves for humans; that copy must never become
// prompt text the AI treats as a description of the real source.
usage_guidance: '',
enabled: true
})
}
// Reset create form
const resetCreateForm = () => {
editingToolId.value = null
Object.assign(createForm, {
name: '',
description: '',
usage_guidance: '',
transport_type: 'stdio' as MCPTransportType,
enabled: true,
command: '',
args: [],
env_vars: {},
url: '',
headers: {},
timeout: DEFAULT_MCP_TIMEOUT,
sse_read_timeout: 60,
terminate_on_close: true
})
}
// Confirm delete. This destroys the connector for the whole organization,
// so the dialog first asks what else points at it.
const confirmDelete = async (toolId: number) => {
deleteTargetId.value = toolId
deleteReferences.value = null
showDeleteConfirm.value = true
try {
deleteReferences.value = await mcpService.getMCPToolReferences(toolId)
} catch {
// Non-fatal — confirm without the reference list rather than blocking.
}
}
// Cancel delete
const cancelDelete = () => {
deleteTargetId.value = null
deleteReferences.value = null
showDeleteConfirm.value = false
}
// Add argument to args array
const addArg = (arg: string) => {
if (arg.trim()) {
createForm.args = [...(createForm.args || []), arg.trim()]
}
}
// Remove argument from args array
const removeArg = (index: number) => {
createForm.args = createForm.args?.filter((_, i) => i !== index) || []
}
// Add environment variable
const addEnvVar = (key: string, value: string) => {
if (key.trim() && value.trim()) {
createForm.env_vars = {
...createForm.env_vars,
[key.trim()]: value.trim()
}
}
}
// Remove environment variable
const removeEnvVar = (key: string) => {
const newEnvVars = { ...createForm.env_vars }
delete newEnvVars[key]
createForm.env_vars = newEnvVars
}
// Add header
const addHeader = (key: string, value: string) => {
if (key.trim() && value.trim()) {
createForm.headers = {
...createForm.headers,
[key.trim()]: value.trim()
}
}
}
// Remove header
const removeHeader = (key: string) => {
const newHeaders = { ...createForm.headers }
delete newHeaders[key]
createForm.headers = newHeaders
}
// Check if tool is linked to agent
const isToolLinked = (toolId: number): boolean => {
return agentMCPTools.value.some(tool => tool.id === toolId)
}
// Get transport type display info
const getTransportTypeInfo = (type: MCPTransportType) => {
return transportTypes.find(t => t.value === type) || transportTypes[0]
}
// Format date
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
return {
// State
agentMCPTools,
availableMCPTools,
isLoading,
isLoadingAvailable,
error,
showCreateModal,
showLinkModal,
showDeleteConfirm,
deleteReferences,
editingToolId,
createForm,
transportTypes,
mcpPresets,
// Methods
fetchAgentMCPTools,
fetchAvailableMCPTools,
saveMCPTool,
startEdit,
linkMCPTool,
unlinkMCPTool,
deleteMCPTool,
testMCPTool,
testResults,
testingToolId,
applyPreset,
resetCreateForm,
confirmDelete,
cancelDelete,
addArg,
removeArg,
addEnvVar,
removeEnvVar,
addHeader,
removeHeader,
isToolLinked,
getTransportTypeInfo,
formatDate
}
}
+256
View File
@@ -0,0 +1,256 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* One-click connect for the Meta channels.
*
* Three different logins hide behind one button: WhatsApp uses Meta's Embedded
* Signup SDK, Messenger uses Facebook Login for Business and then picks a Page,
* and Instagram uses Instagram Login, which involves no Facebook Page at all.
* Kept out of the connect modal so that component stays presentation plus the
* manual credentials form.
*/
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import channelsService, {
type ChannelAccount,
type MessengerSignupPage,
} from '@/services/channels'
import {
loadMetaSdk,
parseSignupMessage,
runBusinessLogin,
runInstagramLogin,
signupLoginOptions,
META_OAUTH_CALLBACK_PATH,
type SignupChannel,
type SignupSession,
} from '@/utils/metaSdk'
/** Copy for the one-click pane, per channel. */
const SIGNUP_COPY: Record<SignupChannel, { intro: string; cta: string }> = {
whatsapp: {
intro: '使用 Facebook 登录并连接你的 WhatsApp Business 号码。',
cta: '使用 Facebook 继续',
},
messenger: {
intro: '使用 Facebook 登录并连接你的主页。',
cta: '使用 Facebook 继续',
},
instagram: {
intro: '使用 Instagram 登录并连接你的专业账号。',
cta: '使用 Instagram 继续',
},
}
export interface MetaSignupOptions {
channel: SignupChannel
/** Manage mode: an already-connected account has nothing to sign up for. */
existingAccount?: ChannelAccount | null
/** Called with the account once a login completes. */
onConnected: (account: ChannelAccount) => void
}
export function useMetaSignup({ channel, existingAccount, onConnected }: MetaSignupOptions) {
// Offered only when the server says this deployment has a Meta app to onboard
// under. Everyone else keeps the manual credentials form.
const signupEnabled = ref(false)
const signingUp = ref(false)
const showManualForm = ref(false)
const configId = ref('')
const appId = ref('')
const graphVersion = ref('')
/** WhatsApp only: its two halves arrive separately and are joined once both land. */
const signupSession = ref<SignupSession | null>(null)
// Facebook Login for Business can grant several Pages; the customer picks one.
const signupPages = ref<MessengerSignupPage[]>([])
const signupToken = ref('')
const connectingPage = ref(false)
const copy = SIGNUP_COPY[channel]
const handleSignupMessage = (event: MessageEvent) => {
const session = parseSignupMessage(event)
if (session) signupSession.value = session
}
onMounted(async () => {
if (existingAccount) return
try {
const config = await channelsService.getEmbeddedSignupConfig(channel)
if (!config.enabled || !config.app_id) return
// The Facebook logins are driven by a Login-for-Business configuration;
// Instagram Login has none, so requiring one here would hide its button.
if (channel !== 'instagram' && !config.config_id) return
// Empty for Instagram Login, which authorizes with the app id alone.
configId.value = config.config_id ?? ''
appId.value = config.app_id
graphVersion.value = config.graph_version
signupEnabled.value = true
// WhatsApp uses the JS SDK (Embedded Signup, which also reports its
// result out of band via postMessage). The other two drive their own
// OAuth popup, so they need neither the SDK nor the message listener.
if (channel === 'whatsapp') {
await loadMetaSdk(config.app_id, config.graph_version)
window.addEventListener('message', handleSignupMessage)
}
} catch (error) {
// Falling back to the manual form is a working path, not an error worth
// interrupting the user for.
console.error('One-click signup unavailable:', error)
}
})
onBeforeUnmount(() => window.removeEventListener('message', handleSignupMessage))
const finishWhatsAppSignup = async (code: string) => {
const session = signupSession.value
if (!session) {
// Signed in, but Meta never reported which number was set up, so there
// is nothing to connect. Say so rather than appearing to do nothing.
toast.error('WhatsApp 注册未完成', {
description: '未设置号码。请重试,或手动输入凭据。',
})
return
}
const account = await channelsService.connectWhatsAppEmbeddedSignup({
code,
waba_id: session.waba_id,
phone_number_id: session.phone_number_id,
})
onConnected(account)
toast.success(`已连接 ${account.display_name || 'WhatsApp'}`)
}
const connectPickedPage = async (pageId: string) => {
const account = await channelsService.connectMessengerSignup({
signup_token: signupToken.value,
page_id: pageId,
})
onConnected(account)
toast.success(`已连接 ${account.display_name || 'Messenger'}`)
}
const finishMessengerSignup = async (code: string, redirectUri: string) => {
const { pages, signup_token } = await channelsService.listMessengerSignupPages(code, redirectUri)
signupToken.value = signup_token
// One Page needs no picker — connect it and go straight to agent assignment.
if (pages.length === 1) {
await connectPickedPage(pages[0].id)
} else {
signupPages.value = pages
}
}
/** The picker's choice — its own loading state, since the login popup is long gone. */
const onPageSelected = async (pageId: string) => {
try {
connectingPage.value = true
await connectPickedPage(pageId)
} catch (error: any) {
toast.error(error?.response?.data?.detail || '无法连接该账号')
} finally {
connectingPage.value = false
}
}
// WhatsApp: the JS SDK's login callback.
const completeWhatsAppSignup = async (response: { authResponse?: { code?: string } }) => {
const code = response.authResponse?.code
if (!code) {
// The popup was dismissed — not an error worth a toast.
signingUp.value = false
return
}
try {
await finishWhatsAppSignup(code)
} catch (error: any) {
toast.error(error?.response?.data?.detail || '无法完成连接')
} finally {
signingUp.value = false
}
}
/** Shared shape of the two popup logins: run it, connect, surface failures. */
const runPopupConnect = async (connect: (redirectUri: string) => Promise<void>) => {
signingUp.value = true
const redirectUri = window.location.origin + META_OAUTH_CALLBACK_PATH
try {
await connect(redirectUri)
} catch (error: any) {
// The user closing the popup is not an error worth a toast.
if (error?.message !== 'cancelled') {
toast.error(error?.response?.data?.detail || error?.message || '无法完成连接')
}
} finally {
signingUp.value = false
}
}
// Messenger: Facebook Login for Business, then pick a Page.
const startMessengerLogin = () => runPopupConnect(async (redirectUri) => {
const code = await runBusinessLogin({
appId: appId.value,
configId: configId.value,
graphVersion: graphVersion.value,
redirectUri,
})
await finishMessengerSignup(code, redirectUri)
})
// Instagram: Instagram Login — one account, so it connects in a single step.
const startInstagramLogin = () => runPopupConnect(async (redirectUri) => {
const code = await runInstagramLogin({ appId: appId.value, redirectUri })
const account = await channelsService.connectInstagramLogin(code, redirectUri)
onConnected(account)
toast.success(`已连接 ${account.display_name || 'Instagram'}`)
})
const startSignup = () => {
if (signingUp.value) return
// Both popups must open synchronously in the click handler or the browser
// blocks them.
if (channel === 'messenger') { void startMessengerLogin(); return }
if (channel === 'instagram') { void startInstagramLogin(); return }
if (!window.FB) return
signupSession.value = null
signingUp.value = true
// The SDK type-checks its callback and rejects an AsyncFunction outright
// ("Expression is of type asyncfunction, not function"), so hand the async
// work off from a plain function rather than passing `async` here.
window.FB.login(
(response) => { void completeWhatsAppSignup(response) },
signupLoginOptions(configId.value, channel),
)
}
return {
signupEnabled,
signingUp,
showManualForm,
signupPages,
connectingPage,
copy,
startSignup,
onPageSelected,
}
}
@@ -0,0 +1,59 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import { notificationService, type NotificationSettings } from '@/services/notification'
export function useNotificationSettings() {
const settings = ref<NotificationSettings | null>(null)
const isLoading = ref(true)
const isSaving = ref(false)
const error = ref<string | null>(null)
async function load() {
isLoading.value = true
try {
settings.value = await notificationService.getSettings()
error.value = null
} catch (e: any) {
error.value = e?.message || '加载通知设置失败'
} finally {
isLoading.value = false
}
}
// Optimistic: the toggle flips immediately and reverts if the save fails,
// so a slow round-trip never makes the switch feel unresponsive.
async function save(patch: Partial<NotificationSettings>) {
if (!settings.value || isSaving.value) return
isSaving.value = true
const previous = { ...settings.value }
Object.assign(settings.value, patch)
try {
settings.value = await notificationService.updateSettings(patch)
} catch (e: any) {
settings.value = previous
toast.error(e?.message || '保存通知设置失败')
} finally {
isSaving.value = false
}
}
onMounted(load)
return { settings, isLoading, isSaving, error, load, save }
}
@@ -0,0 +1,188 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { onMessage, type MessagePayload } from 'firebase/messaging'
import { toast } from 'vue-sonner'
import {
requestNotificationPermission,
refreshFCMToken,
getMessagingIfSupported
} from '@/services/firebase'
import { SW_MESSAGE, CONVERSATIONS_PATH, conversationSessionUrl } from '@/pwa/pushContract'
import mitt from '@/utils/emitter'
// Match backend NotificationType enum
enum NotificationType {
KNOWLEDGE_PROCESSED = 'knowledge_processed',
KNOWLEDGE_FAILED = 'knowledge_failed',
SYSTEM = 'system',
CHAT = 'chat'
}
// Module-level guards: every view mounts its own DashboardLayout (and thus
// this composable), so per-mount attachment would stack a new SW message
// listener + FCM onMessage subscription on every route change — one push
// would then toast once per navigation.
let listenersAttached = false
let tokenRefreshed = false
export function useNotifications() {
const hasPermission = ref(
typeof Notification !== 'undefined' && Notification.permission === 'granted'
)
const router = useRouter()
// A payload that references a conversation gets an "Open" action on its toast
const sessionAction = (payload: MessagePayload) => {
const sessionId = payload.data?.session_id
if (!sessionId) return undefined
return {
label: '打开',
onClick: () => router.push(conversationSessionUrl(sessionId))
}
}
const handleNewNotification = (payload: MessagePayload) => {
const notifType = payload.data?.type?.toLowerCase() || 'default'
// Backend sends data-only messages (title/body inside data) so the SW SDK
// doesn't auto-display duplicates; keep notification?. as a fallback.
const title = payload.data?.title || payload.notification?.title
const body = payload.data?.body || payload.notification?.body
// Handle different notification types
switch (notifType) {
case NotificationType.KNOWLEDGE_PROCESSED:
toast.success(title || '知识库处理完成', {
description: body,
duration: 5000,
position: 'top-right'
})
// Emit event to refresh knowledge grid
mitt.emit('knowledge-updated')
break
case NotificationType.KNOWLEDGE_FAILED:
toast.error(title || '知识库处理失败', {
description: body,
duration: 8000,
position: 'top-right'
})
break
case NotificationType.SYSTEM:
toast(title || '系统通知', {
description: body,
duration: 5000,
position: 'top-right',
action: sessionAction(payload)
})
break
case NotificationType.CHAT:
toast(title || '新会话消息', {
description: body,
duration: 5000,
position: 'top-right',
action: sessionAction(payload)
})
break
default:
toast(title || '新通知', {
description: body,
duration: 5000,
position: 'top-right'
})
}
}
const listenForBgNotification = () => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.eventType === SW_MESSAGE.BACKGROUND_NOTIFICATION) {
handleNewNotification(event.data)
}
// SW notificationclick fallback for tabs it can't navigate() itself.
// Pin the target to the inbox — never route to an arbitrary string
// from a message event.
if (
event.data?.eventType === SW_MESSAGE.OPEN_CONVERSATION &&
typeof event.data.url === 'string' &&
event.data.url.startsWith(CONVERSATIONS_PATH)
) {
router.push(event.data.url)
}
})
}
}
const setFcmMessagingListener = async () => {
const messaging = await getMessagingIfSupported()
if (!messaging) return
onMessage(messaging, (payload) => {
handleNewNotification(payload)
})
}
// Attach listeners — safe on mount, guarded to run once per app load. Does
// NOT ask for permission: on iOS a permission request outside a user gesture
// silently fails, so the request lives behind an explicit button
// (EnablePushPrompt → enableNotifications).
const startListening = async () => {
try {
if (!hasPermission.value || listenersAttached) return
listenersAttached = true
listenForBgNotification()
await setFcmMessagingListener()
// Already-granted users never see the enable prompt again, so this is
// the only path that re-binds their FCM token to the current service
// worker (the old firebase-messaging-sw.js one dies on upgrade).
if (!tokenRefreshed) {
tokenRefreshed = true
refreshFCMToken()
}
} catch (err) {
console.error('Error initializing notification listeners:', err)
}
}
// Gesture-driven: prompt for permission, register the FCM token, then listen
const enableNotifications = async (): Promise<boolean> => {
const granted = await requestNotificationPermission()
hasPermission.value = granted
if (granted) {
// Token was just minted — don't mint again from startListening
tokenRefreshed = true
await startListening()
} else if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
// Permission granted but token registration failed — surface it
toast.error('无法开启通知。请刷新后重试。')
}
return granted
}
onMounted(() => {
startListening()
})
return {
hasPermission,
enableNotifications,
}
}
+265
View File
@@ -0,0 +1,265 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
import { toast } from 'vue-sonner'
import { ticketService } from '@/services/tickets'
import { useTicketSocket } from '@/composables/useTicketSocket'
import type {
InvestigationDetail,
Ticket,
TicketActivity,
TicketDetail,
TicketPriority,
TicketStatus,
TicketUpdatePayload,
} from '@/types/ticket'
// Poll fallback cadence: brisk while an AI run is active, relaxed otherwise.
const ACTIVE_POLL_MS = 3000
const IDLE_POLL_MS = 15000
export function useTicketDetail(ticketId: Ref<string>) {
const detail = ref<TicketDetail | null>(null)
const investigation = ref<InvestigationDetail | null>(null)
const isLoading = ref(true)
const error = ref<string | null>(null)
const isSavingComment = ref(false)
// Covers both customer-summary actions: they write the same field, and
// sending twice delivers the message to the customer twice.
const isRcaBusy = ref(false)
const ticket = computed<Ticket | null>(() => detail.value?.ticket ?? null)
const activities = computed<TicketActivity[]>(() => detail.value?.activities ?? [])
const hasActiveRun = computed(() =>
(detail.value?.runs ?? []).some((r) => r.status === 'pending' || r.status === 'running'),
)
async function refresh(silent = false) {
if (!ticketId.value) return
if (!silent) isLoading.value = true
try {
// The glass box loads alongside the ticket; its failure (e.g. plan
// gate) must never take the whole page down.
const [detailData, investigationData] = await Promise.all([
ticketService.getTicket(ticketId.value),
ticketService.getInvestigation(ticketId.value).catch(() => null),
])
detail.value = detailData
investigation.value = investigationData
error.value = null
} catch (e: any) {
if (!silent) error.value = e?.message || '加载工单失败'
} finally {
isLoading.value = false
}
}
async function patch(patchPayload: TicketUpdatePayload, failureMessage: string) {
if (!detail.value) return
const previous = { ...detail.value.ticket }
Object.assign(detail.value.ticket, patchPayload)
try {
await ticketService.updateTicket(previous.id, patchPayload)
await refresh(true)
} catch (e: any) {
detail.value.ticket = previous
toast.error(e?.message || failureMessage)
}
}
const setStatus = (status: TicketStatus) => patch({ status }, '修改状态失败')
const setPriority = (priority: TicketPriority) => patch({ priority }, '修改优先级失败')
const setSeverity = (severity: number) => patch({ severity }, '修改严重程度失败')
const setTitle = (title: string) => patch({ title }, '重命名工单失败')
const setDescription = (description: string) =>
patch({ description }, '更新描述失败')
const setAssignee = (assignee_user_id: string | null) =>
patch({ assignee_user_id }, '分配工单失败')
async function setCustomer(email: string, name?: string) {
if (!detail.value || !email.trim()) return
try {
await ticketService.updateTicket(detail.value.ticket.id, {
customer_email: email.trim(),
customer_name: name?.trim() || undefined,
})
await refresh(true)
toast.success('客户已关联')
} catch (e: any) {
toast.error(e?.message || '关联客户失败')
}
}
async function addComment(body: string, isInternal = true) {
if (!detail.value || !body.trim()) return
isSavingComment.value = true
try {
await ticketService.addComment(detail.value.ticket.id, body.trim(), isInternal)
await refresh(true)
} catch (e: any) {
toast.error(e?.message || '发表评论失败')
} finally {
isSavingComment.value = false
}
}
async function resolve(payload: { outcome?: string; resolution_summary?: string; customer_message?: string }) {
if (!detail.value) return
try {
await ticketService.resolveTicket(detail.value.ticket.id, payload)
await refresh(true)
toast.success('工单已解决,已通知客户')
} catch (e: any) {
toast.error(e?.message || '解决工单失败')
}
}
async function reopen(reason?: string) {
if (!detail.value) return
try {
await ticketService.reopenTicket(detail.value.ticket.id, reason)
await refresh(true)
} catch (e: any) {
toast.error(e?.message || '重新打开工单失败')
}
}
async function investigate(contextNote?: string) {
if (!detail.value) return
try {
await ticketService.investigate(detail.value.ticket.id, {
run_type: 'investigation',
context_note: contextNote,
})
await refresh(true)
toast.success('AI 调查已加入队列')
} catch (e: any) {
toast.error(e?.message || '启动 AI 运行失败')
}
}
async function saveRcaDraft(customerSummary: string) {
if (!detail.value || isRcaBusy.value) return
isRcaBusy.value = true
try {
await ticketService.updateRca(detail.value.ticket.id, {
customer_summary: customerSummary,
mark_reviewed: true,
})
await refresh(true)
toast.success('草稿已保存')
} catch (e: any) {
toast.error(e?.message || '保存草稿失败')
} finally {
isRcaBusy.value = false
}
}
async function sendRcaToCustomer(customerSummary?: string) {
// Re-entry guard, not just a disabled button: this delivers a message to
// a real customer, so a double-click must not send it twice.
if (!detail.value || isRcaBusy.value) return
isRcaBusy.value = true
try {
// Persist any unsaved edit first so what's sent is what's shown.
if (customerSummary !== undefined) {
await ticketService.updateRca(detail.value.ticket.id, {
customer_summary: customerSummary,
mark_reviewed: true,
})
}
await ticketService.sendRcaToCustomer(detail.value.ticket.id)
await refresh(true)
toast.success('摘要已发送给客户')
} catch (e: any) {
toast.error(e?.message || '发送摘要失败')
} finally {
isRcaBusy.value = false
}
}
async function approveProposal() {
if (!detail.value) return
try {
await ticketService.approveProposal(detail.value.ticket.id)
await refresh(true)
toast.success('方案已批准,工单已解决并通知客户')
} catch (e: any) {
toast.error(e?.message || '批准方案失败')
}
}
async function rejectProposal(reason?: string, reinvestigate = false) {
if (!detail.value) return
try {
await ticketService.rejectProposal(detail.value.ticket.id, reason, reinvestigate)
await refresh(true)
toast.success(reinvestigate ? '已拒绝,已排入新的深入调查' : '方案已拒绝')
} catch (e: any) {
toast.error(e?.message || '拒绝方案失败')
}
}
useTicketSocket((event) => {
if (event.ticket_id === ticketId.value) refresh(true)
})
let pollTimer: ReturnType<typeof setTimeout> | null = null
function scheduleNextPoll() {
pollTimer = setTimeout(async () => {
await refresh(true)
scheduleNextPoll()
}, hasActiveRun.value ? ACTIVE_POLL_MS : IDLE_POLL_MS)
}
onMounted(async () => {
await refresh()
scheduleNextPoll()
})
onBeforeUnmount(() => {
if (pollTimer) clearTimeout(pollTimer)
})
watch(ticketId, () => refresh())
return {
detail,
investigation,
ticket,
activities,
hasActiveRun,
isLoading,
isSavingComment,
isRcaBusy,
error,
refresh,
setStatus,
setPriority,
setSeverity,
setTitle,
setDescription,
setAssignee,
setCustomer,
addComment,
resolve,
reopen,
investigate,
saveRcaDraft,
sendRcaToCustomer,
approveProposal,
rejectProposal,
}
}
@@ -0,0 +1,59 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Live ticket updates: joins the per-org ticket room on the /agent namespace
and forwards `ticket_update` frames. Callers keep a poll fallback — a missed
frame is never fatal.
*/
import { onBeforeUnmount, onMounted } from 'vue'
import { socketService } from '@/services/socket'
import { userService } from '@/services/user'
import type { TicketUpdateEvent } from '@/types/ticket'
export function useTicketSocket(onUpdate: (event: TicketUpdateEvent) => void) {
const orgId = userService.getCurrentUser()?.organization_id
const room = orgId ? `org_tickets_${orgId}` : null
const handleUpdate = (data: unknown) => {
const event = data as TicketUpdateEvent
if (event && event.ticket_id) onUpdate(event)
}
const joinRoom = () => {
if (room) socketService.emit('join_room', { session_id: room })
}
const handleReconnect = () => {
socketService.off('ticket_update', handleUpdate)
socketService.on('ticket_update', handleUpdate)
joinRoom()
}
onMounted(() => {
if (!room) return
socketService.connect()
socketService.on('ticket_update', handleUpdate)
socketService.onReconnect(handleReconnect)
joinRoom()
})
onBeforeUnmount(() => {
if (!room) return
socketService.emit('leave_room', { session_id: room })
socketService.off('ticket_update', handleUpdate)
socketService.offReconnect(handleReconnect)
})
}
@@ -0,0 +1,62 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import { ticketService } from '@/services/tickets'
import type { TicketSettings } from '@/types/ticket'
export function useTicketingSettings() {
const settings = ref<TicketSettings | null>(null)
const isLoading = ref(true)
const isSaving = ref(false)
const error = ref<string | null>(null)
const planGated = ref(false)
async function load() {
isLoading.value = true
try {
settings.value = await ticketService.getSettings()
error.value = null
planGated.value = false
} catch (e: any) {
planGated.value = /plan/i.test(e?.message || '')
error.value = e?.message || '加载工单设置失败'
} finally {
isLoading.value = false
}
}
async function save(patch: Partial<TicketSettings>) {
if (!settings.value || isSaving.value) return
isSaving.value = true
const previous = { ...settings.value }
Object.assign(settings.value, patch)
try {
settings.value = await ticketService.updateSettings(patch)
toast.success('工单设置已保存')
} catch (e: any) {
settings.value = previous
toast.error(e?.message || '保存工单设置失败')
} finally {
isSaving.value = false
}
}
onMounted(load)
return { settings, isLoading, isSaving, error, planGated, load, save }
}
@@ -0,0 +1,179 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ticketService } from '@/services/tickets'
import { useTicketSocket } from '@/composables/useTicketSocket'
import type { Pagination, TicketListFilters, TicketListItem, TicketStats } from '@/types/ticket'
// Server-side filter mapping for the status pills. "breaching" is a virtual
// filter (SLA), approximated client-side over the loaded page.
const OPEN_STATUSES = 'open,triaging,investigating,awaiting_approval,in_progress,reopened,resolved_pending_confirmation'
const SEARCH_DEBOUNCE_MS = 350
// Poll fallback so a missed socket frame never strands the list.
const LIST_REFRESH_MS = 30000
const DEFAULT_FILTERS: TicketListFilters = {
status: 'all',
priority: 'all',
assignee: 'all',
ai: 'all',
search: '',
sort: 'updated',
}
export function useTicketsWorkspace() {
const route = useRoute()
const router = useRouter()
const tickets = ref<TicketListItem[]>([])
const stats = ref<TicketStats | null>(null)
const pagination = ref<Pagination | null>(null)
const page = ref(1)
const isLoading = ref(true)
const error = ref<string | null>(null)
const planGated = ref(false)
const filters = reactive<TicketListFilters>({
...DEFAULT_FILTERS,
...pickFiltersFromQuery(route.query),
})
const phase = computed(() => {
if (isLoading.value && tickets.value.length === 0) return 'loading'
if (tickets.value.length === 0) return 'empty'
return 'populated'
})
const hasActiveFilters = computed(() =>
Object.keys(DEFAULT_FILTERS).some(
(key) => filters[key as keyof TicketListFilters] !== DEFAULT_FILTERS[key as keyof TicketListFilters],
),
)
function pickFiltersFromQuery(query: Record<string, any>): Partial<TicketListFilters> {
const picked: Partial<TicketListFilters> = {}
for (const key of Object.keys(DEFAULT_FILTERS) as (keyof TicketListFilters)[]) {
if (typeof query[key] === 'string' && query[key]) picked[key] = query[key]
}
return picked
}
function filtersToQuery(): Record<string, string> {
const query: Record<string, string> = {}
for (const key of Object.keys(DEFAULT_FILTERS) as (keyof TicketListFilters)[]) {
if (filters[key] !== DEFAULT_FILTERS[key]) query[key] = filters[key]
}
return query
}
function buildParams() {
const params: Record<string, any> = { page: page.value, sort: filters.sort }
if (filters.status === 'open') params.status = OPEN_STATUSES
else if (filters.status === 'awaiting') params.status = 'awaiting_approval'
else if (filters.status === 'resolved') params.status = 'resolved,closed'
else if (filters.status === 'breaching') params.status = OPEN_STATUSES
if (filters.priority !== 'all') params.priority = filters.priority
if (filters.assignee === 'unassigned') params.unassigned = true
else if (filters.assignee !== 'all') params.assignee_id = filters.assignee
if (filters.ai !== 'all') params.ai_state = filters.ai
if (filters.search.trim()) params.search = filters.search.trim()
return params
}
const visibleTickets = computed(() => {
if (filters.status !== 'breaching') return tickets.value
const soon = Date.now() + 30 * 60000
return tickets.value.filter(
(t) => t.sla_due_at && !t.resolved_at && new Date(t.sla_due_at).getTime() <= soon,
)
})
async function refresh(silent = false) {
if (!silent) isLoading.value = true
try {
const [listResponse, statsResponse] = await Promise.all([
ticketService.listTickets(buildParams()),
ticketService.getStats(),
])
tickets.value = listResponse.tickets
pagination.value = listResponse.pagination
stats.value = statsResponse
error.value = null
planGated.value = false
} catch (e: any) {
if (!silent) {
planGated.value = /plan/i.test(e?.message || '')
error.value = e?.message || '加载工单失败'
}
} finally {
isLoading.value = false
}
}
function clearFilters() {
Object.assign(filters, DEFAULT_FILTERS)
page.value = 1
}
// Filters -> URL (shareable) + reload. Search is debounced.
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => ({ ...filters }),
(next, prev) => {
router.replace({ query: filtersToQuery() })
if (next.search !== prev?.search) {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
page.value = 1
refresh()
}, SEARCH_DEBOUNCE_MS)
return
}
page.value = 1
refresh()
},
)
watch(page, () => refresh())
useTicketSocket(() => refresh(true))
let refreshTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
refresh()
refreshTimer = setInterval(() => refresh(true), LIST_REFRESH_MS)
})
onBeforeUnmount(() => {
if (refreshTimer) clearInterval(refreshTimer)
if (searchTimer) clearTimeout(searchTimer)
})
return {
tickets: visibleTickets,
stats,
pagination,
page,
filters,
phase,
isLoading,
error,
planGated,
hasActiveFilters,
refresh,
clearFilters,
}
}
@@ -0,0 +1,569 @@
/*
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ref, type Ref } from 'vue'
import type { Node, Edge } from '@vue-flow/core'
import { toast } from 'vue-sonner'
import { workflowNodeService } from '@/services/workflowNode'
import { workflowService } from '@/services/workflow'
import { workflowCacheStorage } from '@/utils/storage'
import { WorkflowStatus } from '@/types/workflow'
import type { NodeTypeInfo, ValidationResult } from './useWorkflowValidation'
export interface WorkflowManagementOptions {
workflowId: string
selectedNode: Ref<Node | null>
showPropertiesPanel: Ref<boolean>
loading: Ref<boolean>
publishLoading: Ref<boolean>
workflowStatus: Ref<WorkflowStatus>
nodeIdCounter: Ref<number>
availableNodeTypes: NodeTypeInfo[]
validateAllNodes: (nodes: Node[], workflowId: string, edges?: any[]) => ValidationResult
highlightNodesWithErrors: (nodes: Node[], workflowId: string, availableNodeTypes: NodeTypeInfo[], edges?: any[]) => void
resetAllNodesValidationStyle: (nodes: Node[], availableNodeTypes: NodeTypeInfo[]) => void
getNodes: () => Node[]
getEdges: () => Edge[]
addNodes: (nodes: Node[]) => void
addEdges: (edges: Edge[]) => void
removeNodes: (nodeIds: string[]) => void
closePropertiesPanel: () => void
}
export function useWorkflowManagement(options: WorkflowManagementOptions) {
const {
workflowId,
selectedNode,
showPropertiesPanel,
loading,
publishLoading,
workflowStatus,
nodeIdCounter,
availableNodeTypes,
validateAllNodes,
highlightNodesWithErrors,
resetAllNodesValidationStyle,
getNodes,
getEdges,
addNodes,
addEdges,
removeNodes,
closePropertiesPanel
} = options
// Map backend node types to frontend enum values
const mapNodeTypeToFrontend = (backendType: string) => {
const mapping = {
'landing_page': 'landingPage',
'message': 'message',
'llm': 'llm',
'condition': 'condition',
'form': 'form',
'action': 'action',
'human_transfer': 'humanTransfer',
'wait': 'wait',
'end': 'end',
'user_input': 'userInput',
'guardrails': 'guardrails'
}
return mapping[backendType as keyof typeof mapping] || 'message'
}
// Map frontend node types to backend enum values
const mapNodeTypeToBackend = (frontendType: string) => {
const mapping = {
'landingPage': 'landing_page',
'message': 'message',
'llm': 'llm',
'condition': 'condition',
'form': 'form',
'action': 'action',
'humanTransfer': 'human_transfer',
'wait': 'wait',
'end': 'end',
'userInput': 'user_input',
'guardrails': 'guardrails'
}
return mapping[frontendType as keyof typeof mapping] || 'message'
}
// Load workflow data
const loadWorkflowData = async () => {
try {
loading.value = true
// Clear any existing cache and load fresh from API
workflowCacheStorage.clearWorkflowCache(workflowId)
const data = await workflowNodeService.getWorkflowNodes(workflowId, false)
// Convert backend nodes to Vue Flow nodes
const nodes: Node[] = data.nodes.map((node: any) => {
const frontendNodeType = mapNodeTypeToFrontend(node.node_type)
const nodeType = availableNodeTypes.find(t => t.type === frontendNodeType)
return {
id: node.id,
type: 'default',
position: { x: node.position_x, y: node.position_y },
data: {
label: `${nodeType?.icon || '📄'} ${node.name}`,
cleanName: node.name,
description: node.description,
config: node.config,
nodeType: frontendNodeType,
icon: nodeType?.icon || '📄',
color: nodeType?.color || '#6B7280',
// Store all backend properties directly in data for easy access
message_text: node.config?.message_text,
system_prompt: node.system_prompt,
temperature: node.temperature,
model_id: node.model_id,
form_fields: node.form_fields,
form_title: node.form_title,
form_description: node.form_description,
submit_button_text: node.submit_button_text,
form_full_screen: node.form_full_screen,
condition_expression: node.condition_expression,
action_type: node.action_type,
action_config: node.action_config,
transfer_rules: node.transfer_rules,
wait_duration: node.wait_duration,
wait_until_condition: node.wait_until_condition,
landing_page_heading: node.landing_page_heading,
landing_page_content: node.landing_page_content,
// Also spread config for backward compatibility
...node.config
},
style: {
backgroundColor: nodeType?.color || '#6B7280',
color: 'white',
borderColor: nodeType?.color || '#6B7280'
}
}
})
// Convert backend connections to Vue Flow edges
const edges: Edge[] = data.connections.map((conn: any) => ({
id: conn.id,
source: conn.source_node_id,
target: conn.target_node_id,
sourceHandle: conn.source_handle,
targetHandle: conn.target_handle,
label: conn.label,
data: {
condition: conn.condition,
priority: conn.priority,
metadata: conn.connection_metadata
}
}))
addNodes(nodes)
addEdges(edges)
// Update counter for new nodes
const maxCounter = nodes.reduce((max, node) => {
const match = node.id.match(/-(\d+)$/)
if (match) {
return Math.max(max, parseInt(match[1]))
}
return max
}, 0)
nodeIdCounter.value = maxCounter + 1
} catch (error: any) {
console.error('Error loading workflow data:', error)
// Don't show error toast for 404 - it means workflow exists but has no nodes yet
if (error.response?.status !== 404) {
toast.error('加载工作流数据失败', {
position: 'top-center'
})
}
if (error.response?.status === 404) {
console.log('No nodes found for workflow - starting with empty canvas')
}
} finally {
loading.value = false
}
}
// Save node properties
const saveNodeProperties = (properties: any) => {
if (selectedNode.value) {
// Update the node visual data (label and styling)
const nodeType = availableNodeTypes.find(t => t.type === selectedNode.value?.data.nodeType)
selectedNode.value.data.label = `${nodeType?.icon || '📄'} ${properties.name || selectedNode.value.data.cleanName}`
selectedNode.value.data.cleanName = properties.name || selectedNode.value.data.cleanName
selectedNode.value.data.description = properties.description || selectedNode.value.data.description
}
// Reset all validation styling to refresh the validation state based on latest cache data
resetAllNodesValidationStyle(getNodes(), availableNodeTypes)
// Re-validate to ensure errors are cleared if the node is now valid
setTimeout(() => {
const validation = validateAllNodes(getNodes(), workflowId, getEdges())
if (!validation.isValid) {
highlightNodesWithErrors(getNodes(), workflowId, availableNodeTypes, getEdges())
}
}, 100)
}
// Save workflow
const saveWorkflow = async () => {
const nodes = getNodes()
const edges = getEdges()
// Check if there are no nodes to save
if (nodes.length === 0) {
toast.error('无法保存空工作流,请至少添加一个节点。', {
duration: 4000,
closeButton: true,
position: 'top-center'
})
return
}
// Check for at least one connection if multiple nodes exist
if (nodes.length > 1 && edges.length === 0) {
toast.error('无法保存未连接的工作流,请先连接节点再保存。', {
duration: 5000,
closeButton: true,
position: 'top-center'
})
return
}
// For single node workflows, ensure it's a valid starting node type
if (nodes.length === 1) {
const singleNode = nodes[0]
const validStartingNodeTypes = ['landingPage', 'message', 'llm', 'form']
if (!validStartingNodeTypes.includes(singleNode.data.nodeType)) {
toast.error(`工作流仅包含一个 ${singleNode.data.nodeType} 节点,无法保存。工作流必须以落地页、消息、LLM 或表单节点开始。`, {
duration: 6000,
closeButton: true,
position: 'top-center'
})
return
}
}
// Advanced connection validation: Check for isolated nodes
if (nodes.length > 1) {
const connectedNodeIds = new Set<string>()
edges.forEach(edge => {
connectedNodeIds.add(edge.source)
connectedNodeIds.add(edge.target)
})
const isolatedNodes = nodes.filter(node => !connectedNodeIds.has(node.id))
if (isolatedNodes.length > 0) {
const isolatedNodeNames = isolatedNodes.map(node =>
node.data.cleanName || node.data.label || '未命名节点'
).join(', ')
toast.error(`存在孤立节点,无法保存工作流:${isolatedNodeNames}。请先连接所有节点再保存。`, {
duration: 6000,
closeButton: true,
position: 'top-center'
})
return
}
}
// Validate all nodes have required configuration
const validation = validateAllNodes(nodes, workflowId, edges)
if (!validation.isValid) {
highlightNodesWithErrors(nodes, workflowId, availableNodeTypes, edges)
toast.error('工作流存在校验错误,无法保存:\n' + validation.errors.join('\n'), {
duration: 8000,
closeButton: true,
position: 'top-center'
})
return
}
try {
loading.value = true
// Save entire cache to backend and get the updated data with real UUIDs
const savedData = await workflowNodeService.saveWorkflowCache(workflowId)
console.log('Workflow saved, reloading with new UUIDs:', savedData)
// Close properties panel if open to avoid ID mismatch issues
if (showPropertiesPanel.value) {
closePropertiesPanel()
}
// Clear current nodes and edges
removeNodes(getNodes().map(n => n.id))
// Reload workflow data with new UUIDs from backend
await loadWorkflowData()
toast.success('工作流已保存', {
position: 'top-center'
})
} catch (error) {
console.error('Error saving workflow:', error)
toast.error('保存工作流失败', {
position: 'top-center'
})
} finally {
loading.value = false
}
}
// Publish workflow
const publishWorkflow = async () => {
try {
publishLoading.value = true
const nodes = getNodes()
const edges = getEdges()
// Validate connections before publishing
if (nodes.length > 1 && edges.length === 0) {
toast.error('无法发布节点之间没有连接的工作流,请先连接节点再发布。', {
duration: 5000,
closeButton: true,
position: 'top-center'
})
return
}
// For single node workflows, ensure it's a valid starting node type
if (nodes.length === 1) {
const singleNode = nodes[0]
const validStartingNodeTypes = ['landingPage', 'message', 'llm', 'form']
if (!validStartingNodeTypes.includes(singleNode.data.nodeType)) {
toast.error(`工作流仅包含一个 ${singleNode.data.nodeType} 节点,无法发布。工作流必须以落地页、消息、LLM 或表单节点开始。`, {
duration: 6000,
closeButton: true,
position: 'top-center'
})
return
}
}
// Advanced connection validation: Check for isolated nodes
if (nodes.length > 1) {
const connectedNodeIds = new Set<string>()
edges.forEach(edge => {
connectedNodeIds.add(edge.source)
connectedNodeIds.add(edge.target)
})
const isolatedNodes = nodes.filter(node => !connectedNodeIds.has(node.id))
if (isolatedNodes.length > 0) {
const isolatedNodeNames = isolatedNodes.map(node =>
node.data.cleanName || node.data.label || '未命名节点'
).join(', ')
toast.error(`存在孤立节点,无法发布工作流:${isolatedNodeNames}。请先连接所有节点再发布。`, {
duration: 6000,
closeButton: true,
position: 'top-center'
})
return
}
}
// Validate before publishing
const validation = validateAllNodes(nodes, workflowId, edges)
if (!validation.isValid) {
highlightNodesWithErrors(nodes, workflowId, availableNodeTypes, edges)
toast.error('发布前请先修正校验错误:\n' + validation.errors.join('\n'), {
duration: 8000,
closeButton: true,
position: 'top-center'
})
return
}
// First save the workflow to ensure all changes are persisted
await saveWorkflow()
// Then publish it
const updatedWorkflow = await workflowService.publishWorkflow(workflowId)
workflowStatus.value = updatedWorkflow.status
toast.success('工作流已发布!现在已上线,可以处理会话。', {
position: 'top-center'
})
} catch (error) {
console.error('Error publishing workflow:', error)
toast.error('发布工作流失败', {
position: 'top-center'
})
} finally {
publishLoading.value = false
}
}
// Unpublish workflow
const unpublishWorkflow = async () => {
try {
publishLoading.value = true
const updatedWorkflow = await workflowService.unpublishWorkflow(workflowId)
workflowStatus.value = updatedWorkflow.status
toast.success('工作流已取消发布!现在处于草稿模式。', {
position: 'top-center'
})
} catch (error) {
console.error('Error unpublishing workflow:', error)
toast.error('取消发布工作流失败', {
position: 'top-center'
})
} finally {
publishLoading.value = false
}
}
// Handle nodes deletion
const handleNodesDelete = (deletedNodes: Node[]) => {
console.log('handleNodesDelete called with:', deletedNodes)
// Remove deleted nodes from cache
deletedNodes.forEach(node => {
console.log('Deleting node from cache (handleNodesDelete):', node.id)
workflowNodeService.deleteNodeFromCache(workflowId, node.id)
})
// Check if the currently selected node was deleted
if (selectedNode.value && deletedNodes.some(node => node.id === selectedNode.value?.id)) {
console.log('Selected node was deleted, closing properties panel')
closePropertiesPanel()
toast.success('节点已删除', {
position: 'top-center'
})
}
}
// Handle nodes change (e.g., node added, moved, deleted)
const handleNodesChange = (changes: any) => {
console.log('Nodes changed:', changes)
changes.forEach((change: any) => {
switch (change.type) {
case 'remove':
console.log('Node removal detected:', change)
workflowNodeService.deleteNodeFromCache(workflowId, change.id)
if (selectedNode.value && selectedNode.value.id === change.id) {
console.log('Selected node was deleted, closing properties panel')
closePropertiesPanel()
toast.success('节点已删除', {
position: 'top-center'
})
}
break
case 'position':
if (change.position) {
const nodeIndex = getNodes().findIndex(n => n.id === change.id)
if (nodeIndex !== -1) {
const node = getNodes()[nodeIndex]
const cacheNodeData = {
id: node.id,
node_type: mapNodeTypeToBackend(node.data.nodeType),
name: node.data.cleanName || node.data.label || 'Node',
description: node.data.description || '',
position_x: change.position.x,
position_y: change.position.y,
config: node.data.config || {}
}
workflowNodeService.updateNodeInCache(workflowId, cacheNodeData)
}
}
break
}
})
}
// Handle delete event from Vue Flow
const handleDeleteEvent = (event: any) => {
console.log('Vue Flow delete event:', event)
const handleSingleNodeDeletion = (nodeId: string) => {
console.log('Deleting node from cache (handleDeleteEvent):', nodeId)
workflowNodeService.deleteNodeFromCache(workflowId, nodeId)
if (selectedNode.value && selectedNode.value.id === nodeId) {
closePropertiesPanel()
toast.success('节点已删除', {
position: 'top-center'
})
}
}
if (Array.isArray(event)) {
const deletedNodes = event.filter((item: any) => item.type === 'node' || !item.type)
deletedNodes.forEach((node: any) => handleSingleNodeDeletion(node.id))
} else if (event.nodes) {
event.nodes.forEach((node: any) => handleSingleNodeDeletion(node.id))
} else if (event.id) {
handleSingleNodeDeletion(event.id)
}
}
// Handle edge changes (including deletion)
const handleEdgesChange = (changes: any[]) => {
console.log('Edges changed:', changes)
changes.forEach((change: any) => {
switch (change.type) {
case 'remove':
console.log('Edge removal detected:', change)
workflowNodeService.removeConnectionFromCache(workflowId, change.id)
toast.success('连接已删除', {
position: 'top-center'
})
break
// Handle other edge change types if needed in the future
case 'select':
// Edge selection - no cache update needed
break
case 'add':
// Edge addition - already handled in onConnect
break
}
})
}
return {
loadWorkflowData,
saveNodeProperties,
saveWorkflow,
publishWorkflow,
unpublishWorkflow,
handleNodesDelete,
handleNodesChange,
handleDeleteEvent,
handleEdgesChange,
mapNodeTypeToFrontend,
mapNodeTypeToBackend
}
}
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more