chore: make codes the repository root

This commit is contained in:
Eric 1549169735@qq.com
2026-08-06 21:52:03 +08:00
parent de5345607a
commit 23c9f52869
89 changed files with 21 additions and 513 deletions

3
web/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

46
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,46 @@
import axios, { type AxiosRequestConfig } from 'axios'
interface Envelope<T> { code: string; message: string; data: T }
const client = axios.create({ baseURL: '/api/v1', timeout: 15000 })
client.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !error.config?.url?.includes('/auth/login')) {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
if (window.location.pathname !== '/login') window.location.href = '/login'
}
return Promise.reject(error)
},
)
export const api = {
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await client.get<Envelope<T>>(url, config)
return response.data.data
},
async post<T>(url: string, data?: unknown): Promise<T> {
const response = await client.post<Envelope<T>>(url, data)
return response.data.data
},
async put<T>(url: string, data?: unknown): Promise<T> {
const response = await client.put<Envelope<T>>(url, data)
return response.data.data
},
async delete<T>(url: string): Promise<T> {
const response = await client.delete<Envelope<T>>(url)
return response.data.data
},
}
export function apiMessage(error: any): string {
return error?.response?.data?.message || error?.message || '操作失败,请稍后重试'
}

1
web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
AppstoreOutlined,
BookOutlined,
DashboardOutlined,
HistoryOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PlayCircleOutlined,
} from '@ant-design/icons-vue'
const collapsed = ref(false)
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const selectedKeys = computed(() => {
if (route.path.startsWith('/scenarios') || route.path.startsWith('/sops')) return ['scenarios']
if (route.path.startsWith('/execute')) return ['execute']
if (route.path.startsWith('/runs')) return ['runs']
if (route.path.startsWith('/knowledge')) return ['knowledge']
return ['dashboard']
})
const titles: Record<string, string> = {
dashboard: '工作台', scenarios: '场景与 SOP', execute: '执行话术', runs: '执行记录', knowledge: '知识卡',
}
const pageTitle = computed(() => titles[selectedKeys.value[0]] || '销冠 SOP')
onMounted(() => auth.loadUser().catch(() => auth.logout()))
function navigate({ key }: { key: string }) {
const target: Record<string, string> = { dashboard: '/', scenarios: '/scenarios', execute: '/execute', runs: '/runs', knowledge: '/knowledge' }
router.push(target[key])
}
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<a-layout class="app-frame">
<a-layout-sider v-model:collapsed="collapsed" :trigger="null" collapsible :width="224" class="side-panel">
<div class="brand" :class="{ compact: collapsed }">
<div class="brand-mark"><span></span><span></span><span></span></div>
<div v-if="!collapsed" class="brand-copy">
<strong>销冠 SOP</strong>
<small>经验执行系统</small>
</div>
</div>
<a-menu mode="inline" theme="dark" :selected-keys="selectedKeys" @click="navigate">
<a-menu-item key="dashboard"><DashboardOutlined /><span>工作台</span></a-menu-item>
<a-menu-item key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
<a-menu-item key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
<a-menu-item key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
<a-menu-item key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
</a-menu>
<div class="sider-foot" :class="{ compact: collapsed }">
<span class="online-dot"></span>
<span v-if="!collapsed">服务运行正常</span>
</div>
</a-layout-sider>
<a-layout>
<a-layout-header class="topbar">
<a-button type="text" class="collapse-button" :aria-label="collapsed ? '展开导航' : '收起导航'" @click="collapsed = !collapsed">
<MenuUnfoldOutlined v-if="collapsed" /><MenuFoldOutlined v-else />
</a-button>
<div class="topbar-title">{{ pageTitle }}</div>
<a-dropdown placement="bottomRight">
<button class="user-button">
<span class="avatar">{{ (auth.user?.display_name || '管').slice(0, 1) }}</span>
<span class="user-copy">
<strong>{{ auth.user?.display_name || '平台管理员' }}</strong>
<small>{{ auth.user?.role_code === 'admin' ? '企业管理员' : auth.user?.role_code }}</small>
</span>
</button>
<template #overlay>
<a-menu><a-menu-item key="logout" @click="logout"><LogoutOutlined /> 退出登录</a-menu-item></a-menu>
</template>
</a-dropdown>
</a-layout-header>
<a-layout-content class="content-area"><router-view /></a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
.app-frame { min-height: 100vh; }
.side-panel { position: sticky; top: 0; height: 100vh; background: #202825 !important; overflow: hidden; }
.brand { height: 78px; display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid rgba(255,255,255,.09); }
.brand.compact { padding: 0; justify-content: center; }
.brand-mark { width: 32px; height: 32px; display: grid; grid-template-columns: repeat(3, 1fr); align-items: end; gap: 3px; padding: 5px; border: 1px solid rgba(255,255,255,.22); border-radius: 5px; }
.brand-mark span { background: #66c8a6; border-radius: 1px 1px 0 0; }
.brand-mark span:nth-child(1) { height: 8px; }.brand-mark span:nth-child(2) { height: 14px; }.brand-mark span:nth-child(3) { height: 20px; background: #f0a07d; }
.brand-copy { min-width: 0; color: white; display: flex; flex-direction: column; }
.brand-copy strong { font-family: "Noto Serif SC", serif; font-size: 16px; letter-spacing: 0; }
.brand-copy small { color: #a7b7b0; font-size: 11px; margin-top: 2px; }
.side-panel :deep(.ant-menu-dark) { background: transparent; padding: 12px 9px; }
.side-panel :deep(.ant-menu-item) { height: 42px; margin: 5px 0; border-radius: 4px; color: #b9c4c0; }
.side-panel :deep(.ant-menu-item-selected) { background: #166b53 !important; color: white; }
.sider-foot { position: absolute; bottom: 0; left: 0; right: 0; height: 52px; display: flex; align-items: center; gap: 8px; padding: 0 20px; color: #91a29b; font-size: 12px; border-top: 1px solid rgba(255,255,255,.08); }
.sider-foot.compact { justify-content: center; padding: 0; }
.online-dot { width: 7px; height: 7px; border-radius: 50%; background: #67caa8; box-shadow: 0 0 0 3px rgba(103,202,168,.12); }
.topbar { height: 62px; padding: 0 22px; display: flex; align-items: center; gap: 12px; line-height: normal; background: rgba(255,255,255,.94); border-bottom: 1px solid #dce2df; position: sticky; top: 0; z-index: 20; }
.collapse-button { width: 36px; height: 36px; }
.topbar-title { flex: 1; color: #52605a; font-size: 14px; }
.user-button { display: flex; align-items: center; gap: 9px; border: 0; padding: 6px 8px; background: transparent; cursor: pointer; border-radius: 5px; }
.user-button:hover { background: #f1f4f2; }
.avatar { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 4px; background: #e5f2ed; color: #126248; font-weight: 700; }
.user-copy { display: flex; flex-direction: column; text-align: left; }
.user-copy strong { font-size: 13px; }.user-copy small { color: #7c8883; font-size: 11px; margin-top: 2px; }
.content-area { min-width: 0; background: #f4f6f5; }
@media (max-width: 760px) { .side-panel { display: none; }.user-copy { display: none; }.topbar { padding: 0 12px; } }
</style>

9
web/src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import './styles/main.css'
import App from './App.vue'
import router from './router'
createApp(App).use(createPinia()).use(router).use(Antd).mount('#app')

29
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router'
import AppLayout from '@/layouts/AppLayout.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
{
path: '/',
component: AppLayout,
children: [
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: 'scenarios', name: 'scenarios', component: () => import('@/views/ScenariosView.vue') },
{ path: 'scenarios/:id', name: 'scenario-detail', component: () => import('@/views/ScenarioDetailView.vue') },
{ path: 'sops/:id', name: 'sop-editor', component: () => import('@/views/SOPEditorView.vue') },
{ path: 'execute', name: 'execute', component: () => import('@/views/ExecuteView.vue') },
{ path: 'runs', name: 'runs', component: () => import('@/views/RunHistoryView.vue') },
{ path: 'knowledge', name: 'knowledge', component: () => import('@/views/KnowledgeView.vue') },
],
},
],
})
router.beforeEach((to) => {
if (!to.meta.public && !localStorage.getItem('access_token')) return '/login'
if (to.path === '/login' && localStorage.getItem('access_token')) return '/'
})
export default router

34
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { api } from '@/api/client'
import type { User } from '@/types'
interface LoginResult { access_token: string; refresh_token: string; expires_at: string; user: User }
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref(localStorage.getItem('access_token') || '')
const authenticated = computed(() => Boolean(token.value))
async function login(username: string, password: string) {
const result = await api.post<LoginResult>('/auth/login', { username, password })
token.value = result.access_token
user.value = result.user
localStorage.setItem('access_token', result.access_token)
localStorage.setItem('refresh_token', result.refresh_token)
}
async function loadUser() {
if (!token.value) return
user.value = await api.get<User>('/auth/me')
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
return { user, token, authenticated, login, loadUser, logout }
})

48
web/src/styles/main.css Normal file
View File

@@ -0,0 +1,48 @@
:root {
color: #202825;
background: #f4f6f5;
font-family: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
--ink: #202825;
--muted: #69746f;
--line: #dce2df;
--surface: #ffffff;
--canvas: #f4f6f5;
--green: #16775b;
--green-dark: #0f5843;
--coral: #d5644a;
--amber: #b97a20;
}
* { box-sizing: border-box; }
html, body, #app { min-height: 100%; margin: 0; }
body { min-width: 320px; }
button, input, textarea, select { font: inherit; letter-spacing: 0; }
.page-shell { max-width: 1440px; margin: 0 auto; padding: 24px 28px 40px; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
.page-heading h1 { margin: 0; font-family: "Noto Serif SC", "Songti SC", serif; font-size: 26px; line-height: 1.3; font-weight: 700; letter-spacing: 0; }
.page-heading p { margin: 6px 0 0; color: var(--muted); line-height: 1.6; }
.page-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.surface { background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--line); }
.muted { color: var(--muted); }
.empty-copy { max-width: 360px; margin: 0 auto; color: var(--muted); line-height: 1.7; text-align: center; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 7px; background: var(--green); }
.ant-btn { border-radius: 5px; box-shadow: none; }
.ant-btn-primary { background: var(--green); }
.ant-btn-primary:hover { background: var(--green-dark) !important; }
.ant-table-wrapper .ant-table { border-radius: 0 0 6px 6px; }
.ant-table-wrapper .ant-table-thead > tr > th { color: #52605a; font-size: 12px; font-weight: 650; background: #f7f9f8; }
.ant-tag { border-radius: 3px; }
.ant-modal-content, .ant-drawer-content { border-radius: 6px !important; }
.ant-card { border-radius: 6px; }
.ant-input, .ant-input-number, .ant-select-selector, .ant-input-affix-wrapper { border-radius: 4px !important; }
@media (max-width: 760px) {
.page-shell { padding: 18px 14px 28px; }
.page-heading { flex-direction: column; }
.page-actions { width: 100%; }
.page-heading h1 { font-size: 23px; }
}

10
web/src/types/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface BaseEntity { id: number; created_at: string; updated_at: string }
export interface User { user_id: number; tenant_id: number; role_code: string; username: string; display_name: string }
export interface Scenario extends BaseEntity { tenant_id: number; name: string; industry: string; role_name: string; goal: string; trigger_text: string; visibility: string; status: string; created_by: number }
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string }
export interface SOPVersion extends BaseEntity { sop_id: number; version: number; status: string; start_node_key: string; published_at?: string }
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number }
export interface SOPEdge extends BaseEntity { source_node_key: string; target_node_key: string; condition: Record<string, any>; priority: number }
export interface SOPRun extends BaseEntity { sop_id: number; sop_version_id: number; current_node_key: string; status: string; answers: Record<string, any>; result: string; started_at: string; completed_at?: string; sop_name?: string }
export interface KnowledgeCard extends BaseEntity { scenario_id: number; title: string; status: string; content?: Record<string, any> }

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ArrowRightOutlined, AppstoreOutlined, CheckCircleOutlined, PlayCircleOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { api } from '@/api/client'
const router = useRouter()
const loading = ref(true)
const summary = ref({ scenarios: 0, published_sops: 0, runs: 0, completed_runs: 0 })
onMounted(async () => {
try { summary.value = await api.get('/dashboard/summary') } finally { loading.value = false }
})
</script>
<template>
<div class="page-shell">
<div class="page-heading">
<div><h1>今天从经验开始</h1><p>查看知识沉淀和一线执行的最新状态</p></div>
<div class="page-actions"><a-button type="primary" @click="router.push('/scenarios')"><PlusOutlined />创建场景</a-button></div>
</div>
<a-skeleton :loading="loading" active>
<section class="metric-strip surface">
<div class="metric"><span class="metric-icon green"><AppstoreOutlined /></span><div><b>{{ summary.scenarios }}</b><small>可用场景</small></div></div>
<div class="metric"><span class="metric-icon coral"><CheckCircleOutlined /></span><div><b>{{ summary.published_sops }}</b><small>已发布 SOP</small></div></div>
<div class="metric"><span class="metric-icon amber"><PlayCircleOutlined /></span><div><b>{{ summary.runs }}</b><small>累计执行</small></div></div>
<div class="metric"><span class="completion">{{ summary.runs ? Math.round(summary.completed_runs / summary.runs * 100) : 0 }}%</span><div><b>{{ summary.completed_runs }}</b><small>完成执行</small></div></div>
</section>
</a-skeleton>
<section class="work-grid">
<div class="surface action-panel">
<div class="panel-kicker">常用入口</div>
<button @click="router.push('/scenarios')"><span>配置新的业务场景<small>定义字段目标和触发条件</small></span><ArrowRightOutlined /></button>
<button @click="router.push('/execute')"><span>开始执行已发布 SOP<small>根据客户回答逐步推进</small></span><ArrowRightOutlined /></button>
<button @click="router.push('/knowledge')"><span>维护审核知识卡<small>统一口径与风险提示</small></span><ArrowRightOutlined /></button>
</div>
<div class="surface doctrine-panel">
<div class="panel-kicker">平台原则</div>
<blockquote>一句好话术不应只被收藏它应该知道何时出现下一步去哪以及是否真的有效</blockquote>
<div class="flow-note"><span>创建</span><i></i><span>发布</span><i></i><span>执行</span><i></i><span>复盘</span></div>
</div>
</section>
</div>
</template>
<style scoped>
.metric-strip { display: grid; grid-template-columns: repeat(4, 1fr); overflow: hidden; }
.metric { min-height: 116px; display: flex; align-items: center; gap: 16px; padding: 22px; border-right: 1px solid var(--line); }.metric:last-child { border-right: 0; }
.metric-icon, .completion { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 5px; font-size: 19px; }.metric-icon.green { background: #e5f3ee; color: #16775b; }.metric-icon.coral { background: #faece7; color: #c4573f; }.metric-icon.amber { background: #f8efdf; color: #a86e1e; }
.completion { color: #202825; background: #e8ecea; font-size: 12px; font-weight: 800; }
.metric div { display: flex; flex-direction: column; }.metric b { font-family: "Noto Serif SC", serif; font-size: 28px; line-height: 1; }.metric small { margin-top: 8px; color: var(--muted); }
.work-grid { display: grid; grid-template-columns: 1.05fr .95fr; gap: 18px; margin-top: 18px; }
.action-panel, .doctrine-panel { padding: 22px; }
.panel-kicker { margin-bottom: 14px; color: #7c8883; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.action-panel button { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 15px 0; color: var(--ink); text-align: left; background: transparent; border: 0; border-bottom: 1px solid var(--line); cursor: pointer; }.action-panel button:last-child { border-bottom: 0; }.action-panel button:hover { color: var(--green); }
.action-panel button span { display: flex; flex-direction: column; font-weight: 650; }.action-panel button small { margin-top: 4px; color: var(--muted); font-weight: 400; }
.doctrine-panel { background: #202825; border-color: #202825; color: white; }.doctrine-panel .panel-kicker { color: #89a099; }
blockquote { margin: 30px 0 42px; font-family: "Noto Serif SC", serif; font-size: 22px; line-height: 1.65; }
.flow-note { display: flex; align-items: center; color: #9cafaa; font-size: 12px; }.flow-note i { flex: 1; height: 1px; margin: 0 10px; background: #4a5a54; }
@media (max-width: 900px) { .metric-strip { grid-template-columns: repeat(2,1fr); }.metric:nth-child(2) { border-right: 0; }.metric:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.work-grid { grid-template-columns: 1fr; } }
@media (max-width: 520px) { .metric { min-height: 100px; padding: 16px; }.metric b { font-size: 24px; }.metric-icon, .completion { display: none; } }
</style>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import type { ScenarioField, SOPNode, SOPRun } from '@/types'
interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number }
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] }
const loading=ref(true);const sops=ref<PublishedSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false)
const currentConfig=computed(()=>active.value?.node.config||{})
const currentFields=computed(()=>{if(!active.value)return[];const node=active.value.node;if(node.type==='question'||node.type==='choice')return active.value.fields.filter(f=>f.field_key===currentConfig.value.field_key);if(node.type==='form')return active.value.fields.filter(f=>(currentConfig.value.field_keys||[]).includes(f.field_key));return[]})
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}}
async function next(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{answers:{...answers}});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
function inputFor(field:ScenarioField){return field.field_type}
function reset(){active.value=null;load()}
onMounted(load)
</script>
<template>
<div class="page-shell execution-shell">
<div class="page-heading"><div><h1>执行话术</h1><p>选择已发布 SOP系统会根据客户回答给出下一步</p></div></div>
<a-spin :spinning="loading">
<div v-if="!active" class="sop-catalog">
<button v-for="item in sops" :key="item.id" class="sop-entry surface" @click="start(item.id)"><span class="entry-seq">{{ String(item.id).padStart(2,'0') }}</span><div><small>{{ item.scenario_name }} · V{{ item.version }}</small><h2>{{ item.name }}</h2><p>{{ item.description || '按标准步骤执行这套话术流程。' }}</p></div><span class="play"><PlayCircleOutlined /></span></button>
<a-empty v-if="!sops.length" description="还没有已发布的 SOP" />
</div>
<div v-else class="run-workspace">
<aside class="run-context">
<span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span>
<h2>执行进行中</h2><p>客户回答会自动保存在当前执行记录中</p>
<div class="context-meta"><span>当前节点</span><b>{{ active.node.title }}</b></div><div class="context-meta"><span>已采集字段</span><b>{{ Object.keys(active.run.answers || {}).length }}</b></div>
<div class="privacy-note"><SafetyCertificateOutlined /><span>敏感信息请遵循企业数据规范</span></div>
</aside>
<main class="conversation surface">
<div class="conversation-progress"><span :class="{done:active.run.status==='completed'}"></span><b>{{ active.run.status==='completed'?'流程已完成':'正在执行' }}</b></div>
<div class="node-content"><small>{{ active.node.type.toUpperCase() }}</small><h1>{{ active.node.title }}</h1><blockquote v-if="active.node.content">{{ active.node.content }}</blockquote></div>
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.node.content }}</p><a-button type="primary" @click="reset">执行另一套 SOP</a-button></div>
<template v-else>
<a-form layout="vertical" class="answer-form">
<a-form-item v-for="field in currentFields" :key="field.id" :label="field.field_name" :required="field.required">
<a-input v-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" />
<a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" />
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" style="width:100%" />
<a-radio-group v-else-if="inputFor(field)==='boolean'" v-model:value="answers[field.field_key]"><a-radio :value="true">是</a-radio><a-radio :value="false"></a-radio></a-radio-group>
<a-select v-else-if="inputFor(field)==='select'" v-model:value="answers[field.field_key]" :options="(field.options||[]).map(value=>({value,label:value}))" />
<a-select v-else-if="inputFor(field)==='multiselect'" v-model:value="answers[field.field_key]" mode="multiple" :options="(field.options||[]).map(value=>({value,label:value}))" />
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" style="width:100%" />
<a-input v-else v-model:value="answers[field.field_key]" />
</a-form-item>
<a-form-item v-if="active.node.type==='choice'&&currentFields.length===0"><a-radio-group v-model:value="answers[currentConfig.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group></a-form-item>
</a-form>
<div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div>
</template>
</main>
</div>
</a-spin>
</div>
</template>
<style scoped>
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}
@media(max-width:800px){.sop-catalog{grid-template-columns:1fr}.run-workspace{grid-template-columns:1fr}.run-context{position:static}.conversation{padding:22px}.run-actions{margin:24px -22px -22px;padding:16px 22px}.run-actions span{display:none}}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { BookOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import type { KnowledgeCard, Scenario } from '@/types'
const loading=ref(false);const open=ref(false);const items=ref<KnowledgeCard[]>([]);const scenarios=ref<Scenario[]>([])
const form=reactive({scenario_id:undefined as number|undefined,title:'',content:'',forbidden:'',risk_note:''})
const scenarioOptions=computed(()=>scenarios.value.map(s=>({value:s.id,label:s.name})))
async function load(){loading.value=true;try{const [cards,sceneData]=await Promise.all([api.get<{items:KnowledgeCard[]}>('/knowledge-cards'),api.get<{items:Scenario[]}>('/scenarios')]);items.value=cards.items;scenarios.value=sceneData.items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
async function create(){try{await api.post('/knowledge-cards',{scenario_id:form.scenario_id,title:form.title,content:{standard_copy:form.content,forbidden_copy:form.forbidden,risk_note:form.risk_note}});message.success('知识卡已发布');open.value=false;Object.assign(form,{scenario_id:undefined,title:'',content:'',forbidden:'',risk_note:''});await load()}catch(error){message.error(apiMessage(error))}}
function remove(item:KnowledgeCard){Modal.confirm({title:`归档“${item.title}”?`,okType:'danger',async onOk(){await api.delete(`/knowledge-cards/${item.id}`);await load()}})}
function contentOf(item:KnowledgeCard,key:string){return item.content?.[key]||''}
onMounted(load)
</script>
<template><div class="page-shell"><div class="page-heading"><div><h1>知识卡</h1><p>沉淀经过审核的标准表达禁用语和风险提醒</p></div><a-button type="primary" @click="open=true"><PlusOutlined />新建知识卡</a-button></div><a-spin :spinning="loading"><div v-if="items.length" class="knowledge-grid"><article v-for="item in items" :key="item.id" class="knowledge-card surface"><header><span><BookOutlined /></span><a-tag color="green">已发布</a-tag></header><h2>{{ item.title }}</h2><p>{{ contentOf(item,'standard_copy')||'暂无标准话术' }}</p><div v-if="contentOf(item,'risk_note')" class="risk-note">{{ contentOf(item,'risk_note') }}</div><footer><span>{{ scenarios.find(s=>s.id===item.scenario_id)?.name||'未分类场景' }}</span><a-button type="text" danger aria-label="归档知识卡" @click="remove(item)"><DeleteOutlined /></a-button></footer></article></div><a-empty v-else description="还没有知识卡" /></a-spin><a-drawer v-model:open="open" title="新建知识卡" width="520"><a-form layout="vertical" :model="form" @finish="create"><a-form-item label="所属场景" name="scenario_id" :rules="[{required:true,message:'请选择场景'}]"><a-select v-model:value="form.scenario_id" :options="scenarioOptions" /></a-form-item><a-form-item label="标题" name="title" :rules="[{required:true,message:'请输入标题'}]"><a-input v-model:value="form.title" /></a-form-item><a-form-item label="标准话术" name="content" :rules="[{required:true,message:'请输入标准话术'}]"><a-textarea v-model:value="form.content" :rows="5" /></a-form-item><a-form-item label="禁用表达"><a-textarea v-model:value="form.forbidden" :rows="3" /></a-form-item><a-form-item label="风险提醒"><a-textarea v-model:value="form.risk_note" :rows="3" /></a-form-item><a-button type="primary" html-type="submit" block>发布知识卡</a-button></a-form></a-drawer></div></template>
<style scoped>.knowledge-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.knowledge-card{min-height:250px;padding:20px;display:flex;flex-direction:column}.knowledge-card header{display:flex;justify-content:space-between}.knowledge-card header>span{width:36px;height:36px;display:grid;place-items:center;color:var(--green);background:#e8f3ef;border-radius:4px}.knowledge-card h2{margin:20px 0 10px;font-family:"Noto Serif SC",serif;font-size:19px}.knowledge-card>p{margin:0;color:#4f5c56;line-height:1.7}.risk-note{margin-top:14px;padding:9px 11px;color:#8b5b16;background:#fbf3e5;border-left:2px solid #c68a37;font-size:12px}.knowledge-card footer{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:18px;color:var(--muted);font-size:12px}@media(max-width:1050px){.knowledge-grid{grid-template-columns:repeat(2,1fr)}}@media(max-width:650px){.knowledge-grid{grid-template-columns:1fr}}</style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { LockOutlined, UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { apiMessage } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const form = reactive({ username: 'admin', password: 'admin123' })
async function submit() {
loading.value = true
try {
await auth.login(form.username, form.password)
await router.push('/')
} catch (error) {
message.error(apiMessage(error))
} finally {
loading.value = false
}
}
</script>
<template>
<main class="login-page">
<section class="identity-panel">
<div class="identity-grid"></div>
<div class="brand-lockup">
<div class="brand-symbol"><i></i><i></i><i></i></div>
<span>销冠 SOP</span>
</div>
<div class="identity-copy">
<p class="eyebrow">SALES PRACTICE SYSTEM</p>
<h1>把经验变成<br />每天可执行的步骤</h1>
<p>场景配置话术编排版本发布与一线执行在同一个工作台完成</p>
</div>
<div class="signal-line"><span>01</span><b></b><span>结构化经验</span></div>
</section>
<section class="login-panel">
<div class="login-form-wrap">
<div class="mobile-brand">销冠 SOP</div>
<h2>登录工作台</h2>
<p>使用企业账号进入经验执行系统</p>
<a-form layout="vertical" :model="form" @finish="submit">
<a-form-item label="用户名" name="username" :rules="[{ required: true, message: '请输入用户名' }]">
<a-input v-model:value="form.username" size="large" autocomplete="username"><template #prefix><UserOutlined /></template></a-input>
</a-form-item>
<a-form-item label="密码" name="password" :rules="[{ required: true, message: '请输入密码' }]">
<a-input-password v-model:value="form.password" size="large" autocomplete="current-password"><template #prefix><LockOutlined /></template></a-input-password>
</a-form-item>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">进入平台</a-button>
</a-form>
<div class="login-note"><span></span>本地开发账号已预填</div>
</div>
</section>
</main>
</template>
<style scoped>
.login-page { min-height: 100vh; display: grid; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: #f8faf9; }
.identity-panel { position: relative; overflow: hidden; min-height: 640px; padding: 42px 54px; display: flex; flex-direction: column; color: white; background: #202825; }
.identity-grid { position: absolute; inset: 0; opacity: .12; background-image: linear-gradient(#b7c4be 1px, transparent 1px), linear-gradient(90deg, #b7c4be 1px, transparent 1px); background-size: 44px 44px; }
.brand-lockup { position: relative; display: flex; align-items: center; gap: 12px; font-family: "Noto Serif SC", serif; font-size: 18px; font-weight: 700; }
.brand-symbol { width: 34px; height: 34px; display: grid; grid-template-columns: repeat(3,1fr); align-items: end; gap: 3px; padding: 6px; border: 1px solid rgba(255,255,255,.35); border-radius: 5px; }
.brand-symbol i { display: block; height: 8px; background: #6cc7a8; }.brand-symbol i:nth-child(2) { height: 15px; }.brand-symbol i:nth-child(3) { height: 22px; background: #ed8e6a; }
.identity-copy { position: relative; margin: auto 0; max-width: 600px; }
.eyebrow { color: #86d3b8 !important; font-size: 12px; font-weight: 700; }
.identity-copy h1 { margin: 20px 0 24px; font-family: "Noto Serif SC", "Songti SC", serif; font-size: clamp(38px, 4.2vw, 64px); line-height: 1.2; letter-spacing: 0; }
.identity-copy p { max-width: 510px; color: #bdc8c3; font-size: 16px; line-height: 1.8; }
.signal-line { position: relative; display: flex; align-items: center; gap: 12px; color: #91a29b; font-size: 12px; }
.signal-line b { width: 70px; height: 1px; background: #52615b; }
.login-panel { display: grid; place-items: center; padding: 40px; }
.login-form-wrap { width: min(100%, 390px); }
.login-form-wrap h2 { margin: 0; color: #202825; font-family: "Noto Serif SC", serif; font-size: 28px; letter-spacing: 0; }
.login-form-wrap > p { margin: 8px 0 30px; color: #728079; }
.login-form-wrap :deep(.ant-form-item-label label) { color: #46514c; font-weight: 600; }
.login-form-wrap :deep(.ant-input-affix-wrapper) { padding: 10px 12px; }
.login-form-wrap :deep(.ant-btn-lg) { height: 44px; margin-top: 8px; }
.login-note { display: flex; align-items: center; gap: 8px; margin-top: 20px; color: #8a9691; font-size: 12px; }
.login-note span { width: 7px; height: 7px; border-radius: 50%; background: #d5644a; }
.mobile-brand { display: none; }
@media (max-width: 860px) { .login-page { grid-template-columns: 1fr; }.identity-panel { display: none; }.login-panel { min-height: 100vh; padding: 28px; }.mobile-brand { display: block; margin-bottom: 48px; color: #16775b; font-family: "Noto Serif SC", serif; font-weight: 700; } }
</style>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, apiMessage } from '@/api/client'
import { message } from 'ant-design-vue'
import type { SOPRun } from '@/types'
const loading=ref(false);const items=ref<SOPRun[]>([])
async function load(){loading.value=true;try{items.value=(await api.get<{items:SOPRun[]}>('/runs')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
function statusText(v:string){return v==='completed'?'已完成':'进行中'}
onMounted(load)
</script>
<template><div class="page-shell"><div class="page-heading"><div><h1>执行记录</h1><p>查看 SOP 的实际使用情况和执行结果</p></div></div><section class="surface"><a-table row-key="id" :loading="loading" :data-source="items" :pagination="{pageSize:20}" :columns="[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP',dataIndex:'sop_name'},{title:'状态',dataIndex:'status',width:110},{title:'结果',dataIndex:'result',width:120},{title:'开始时间',dataIndex:'started_at',width:190},{title:'完成时间',dataIndex:'completed_at',width:190}]" :scroll="{x:900}"><template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ record.result||'-' }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template></template><template #emptyText><div class="empty-copy">还没有执行记录发布 SOP 后即可开始执行</div></template></a-table></section></div></template>
<style scoped>code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}</style>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, SaveOutlined, SendOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import type { SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
const route = useRoute(); const router = useRouter(); const id = Number(route.params.id)
const loading = ref(true); const saving = ref(false)
const sop = ref<SOP | null>(null); const version = ref<SOPVersion | null>(null)
const nodes = ref<SOPNode[]>([]); const edges = ref<SOPEdge[]>([]); const selectedKey = ref('')
const selected = computed(() => nodes.value.find(n => n.node_key === selectedKey.value))
const editable = computed(() => version.value?.status === 'draft')
const reviewing = computed(() => version.value?.status === 'reviewing')
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'choice',label:'选择判断'},{value:'condition',label:'条件节点'},{value:'knowledge',label:'知识卡'},{value:'escalate',label:'转人工/转诊'},{value:'finish',label:'结束'}]
const edgeDraft = reactive({ target_node_key: '', field: '', operator: 'equals', value: '' })
async function load() {
loading.value = true
try {
const data = await api.get<{ sop:SOP; version:SOPVersion; nodes:SOPNode[]; edges:SOPEdge[] }>(`/sops/${id}`)
sop.value=data.sop; version.value=data.version; nodes.value=data.nodes.map(n=>({...n,config:n.config||{}})); edges.value=data.edges.map(e=>({...e,condition:e.condition||{}})); selectedKey.value ||= nodes.value[0]?.node_key || ''
} catch(error) { message.error(apiMessage(error)) } finally { loading.value=false }
}
function addNode() {
const key=`node_${Date.now()}`; nodes.value.push({ id:0,created_at:'',updated_at:'',node_key:key,type:'message',title:'新步骤',content:'',config:{},position_x:0,position_y:nodes.value.length*120 }); selectedKey.value=key
}
function removeNode(node:SOPNode) {
if (['start'].includes(node.type)) return message.warning('开始节点不能删除')
Modal.confirm({title:`删除“${node.title}”?`,content:'与该节点关联的转移条件也会删除。',okType:'danger',onOk(){nodes.value=nodes.value.filter(n=>n.node_key!==node.node_key);edges.value=edges.value.filter(e=>e.source_node_key!==node.node_key&&e.target_node_key!==node.node_key);selectedKey.value=nodes.value[0]?.node_key||''}})
}
function move(index:number,delta:number){const target=index+delta;if(target<0||target>=nodes.value.length)return;const copy=[...nodes.value];[copy[index],copy[target]]=[copy[target],copy[index]];copy.forEach((n,i)=>n.position_y=i*120);nodes.value=copy}
function outgoing(key:string){return edges.value.filter(e=>e.source_node_key===key)}
function addEdge(){if(!selected.value||!edgeDraft.target_node_key)return;const condition=edgeDraft.field?{field:edgeDraft.field,operator:edgeDraft.operator,value:parseValue(edgeDraft.value)}:{};edges.value.push({id:0,created_at:'',updated_at:'',source_node_key:selected.value.node_key,target_node_key:edgeDraft.target_node_key,condition,priority:outgoing(selected.value.node_key).length});Object.assign(edgeDraft,{target_node_key:'',field:'',operator:'equals',value:''})}
function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(e=>e!==edge)}
function parseValue(value:string){if(value==='true')return true;if(value==='false')return false;if(value!==''&&!Number.isNaN(Number(value)))return Number(value);return value}
function conditionText(condition:Record<string,any>){if(!condition||!condition.field)return '默认路径';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',exists:'已填写',not_exists:'未填写'};return `${condition.field} ${names[condition.operator]||condition.operator} ${condition.value ?? ''}`}
async function save(){saving.value=true;try{await api.put(`/sops/${id}/draft`,{start_node_key:version.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('草稿已保存');await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
async function validate(){try{const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join('')})}catch(error){message.error(apiMessage(error))}}
async function submitReview(){try{await save();await api.post(`/sops/${id}/submit-review`);message.success('SOP 已提交审核');await load()}catch(error){message.error(apiMessage(error))}}
async function publish(){try{if(editable.value) await save();await api.post(`/sops/${id}/publish`);message.success('SOP 已发布');await load()}catch(error){message.error(apiMessage(error))}}
async function offline(){try{await api.post(`/sops/${id}/offline`);message.success('SOP 已下线');await load()}catch(error){message.error(apiMessage(error))}}
async function createVersion(){try{await api.post(`/sops/${id}/versions`);message.success('已创建新草稿版本');await load()}catch(error){message.error(apiMessage(error))}}
onMounted(load)
</script>
<template>
<div class="editor-page">
<header class="editor-header">
<div class="editor-title"><a-button type="text" aria-label="返回场景" @click="router.push(`/scenarios/${sop?.scenario_id}`)"><ArrowLeftOutlined /></a-button><div><b>{{ sop?.name || 'SOP 编辑器' }}</b><span>版本 {{ version?.version }} · {{ editable ? '草稿' : (reviewing ? '审核中' : (version?.status === 'offline' ? '已下线' : '已发布')) }}</span></div></div>
<div class="page-actions"><a-button @click="validate">校验流程</a-button><a-button v-if="editable" :loading="saving" @click="save"><SaveOutlined />保存</a-button><a-button v-if="editable" @click="submitReview"><SendOutlined />提交审核</a-button><a-button v-if="editable||reviewing" type="primary" @click="publish"><SendOutlined />发布</a-button><a-button v-if="version?.status === 'published'" danger @click="offline">下线</a-button><a-button v-if="!editable&&version?.status !== 'reviewing'&&version?.status !== 'published'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button><a-button v-if="version?.status === 'published'" type="primary" @click="createVersion"><PlusOutlined />创建新版本</a-button></div>
</header>
<a-spin :spinning="loading">
<main class="editor-workbench">
<aside class="node-rail surface">
<div class="rail-title"><span>流程节点</span><a-button v-if="editable" type="text" aria-label="添加节点" @click="addNode"><PlusOutlined /></a-button></div>
<div class="node-list">
<button v-for="(node,index) in nodes" :key="node.node_key" :class="{active:selectedKey===node.node_key}" @click="selectedKey=node.node_key"><span class="node-index">{{ String(index+1).padStart(2,'0') }}</span><span class="node-meta"><b>{{ node.title }}</b><small>{{ nodeTypes.find(t=>t.value===node.type)?.label || node.type }}</small></span><span v-if="editable&&node.type!=='start'" class="node-move"><ArrowUpOutlined @click.stop="move(index,-1)"/><ArrowDownOutlined @click.stop="move(index,1)"/></span></button>
</div>
</aside>
<section v-if="selected" class="node-editor surface">
<div class="node-editor-head"><div><span class="node-type-mark">{{ selected.type.toUpperCase() }}</span><h2>{{ selected.title }}</h2></div><a-button v-if="editable&&selected.type!=='start'" danger type="text" @click="removeNode(selected)"><DeleteOutlined />删除</a-button></div>
<a-form layout="vertical" class="property-form">
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!editable" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!editable||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!editable" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入字段标识"><a-input v-model:value="selected.config.field_key" :disabled="!editable" placeholder="例如 pet_weight" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!editable" /></a-form-item></div></template>
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!editable" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
</a-form>
<div class="transition-head"><div><b>下一步路径</b><span>按优先级匹配默认路径建议放最后</span></div></div>
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="editable" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
<div v-if="editable&&!['finish','escalate'].includes(selected.type)" class="edge-builder"><a-select v-model:value="edgeDraft.target_node_key" placeholder="目标节点" :options="nodes.filter(n=>n.node_key!==selectedKey).map(n=>({value:n.node_key,label:n.title}))" /><a-input v-model:value="edgeDraft.field" placeholder="条件字段(留空为默认)" /><a-select v-model:value="edgeDraft.operator" :options="[{value:'equals',label:'等于'},{value:'not_equals',label:'不等于'},{value:'contains',label:'包含'},{value:'greater_than',label:'大于'},{value:'less_than',label:'小于'},{value:'exists',label:'已填写'}]" /><a-input v-model:value="edgeDraft.value" placeholder="条件值" /><a-button @click="addEdge"><PlusOutlined />添加路径</a-button></div>
</section>
<a-empty v-else description="请选择一个节点" class="surface editor-empty" />
</main>
</a-spin>
</div>
</template>
<style scoped>
.editor-page { min-height: calc(100vh - 62px); background: #eef1ef; }.editor-header { min-height: 66px; display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 24px;background:white;border-bottom:1px solid var(--line);position:sticky;top:62px;z-index:10 }.editor-title{display:flex;align-items:center;gap:8px}.editor-title>div{display:flex;flex-direction:column}.editor-title b{font-size:15px}.editor-title span{margin-top:3px;color:var(--muted);font-size:11px}.editor-workbench{display:grid;grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px}.node-rail{align-self:start;overflow:hidden;position:sticky;top:146px}.rail-title{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--line);font-weight:650}.node-list{padding:8px}.node-list button{width:100%;min-height:58px;display:grid;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;padding:7px 8px;text-align:left;background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}.node-list button:hover{background:#f5f8f6}.node-list button.active{background:#edf6f2;border-color:#b9d8cc}.node-index{color:#88958f;font-family:monospace;font-size:11px}.node-meta{min-width:0;display:flex;flex-direction:column}.node-meta b{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.node-meta small{margin-top:3px;color:var(--muted);font-size:11px}.node-move{display:flex;gap:4px;color:#87938e}.node-move>*:hover{color:var(--green)}.node-editor{align-self:start;min-height:600px;padding:24px}.node-editor-head{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--line)}.node-editor-head h2{margin:6px 0 0;font-family:"Noto Serif SC",serif;font-size:22px}.node-type-mark{color:var(--green);font-size:10px;font-weight:750}.property-form{padding-top:22px}.property-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.transition-head{display:flex;justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;border-top:1px solid var(--line)}.transition-head>div{display:flex;flex-direction:column}.transition-head span{margin-top:4px;color:var(--muted);font-size:11px}.transition-row{min-height:48px;display:grid;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;border-bottom:1px solid #edf0ee}.route-line{width:16px;height:1px;background:var(--green)}.route-target{font-weight:600}.route-empty{padding:20px 0;color:var(--muted);font-size:12px}.edge-builder{display:grid;grid-template-columns:1.1fr 1fr 120px 1fr auto;gap:8px;padding-top:16px}.editor-empty{min-height:400px;display:grid;place-items:center}
@media(max-width:1050px){.edge-builder{grid-template-columns:1fr 1fr}.editor-workbench{grid-template-columns:230px minmax(0,1fr)}}@media(max-width:760px){.editor-header{top:62px;align-items:flex-start;flex-direction:column;padding:12px}.editor-workbench{grid-template-columns:1fr;padding:10px}.node-rail{position:static}.property-grid,.edge-builder{grid-template-columns:1fr}.node-editor{padding:16px}.transition-head{margin:8px -16px 0;padding:16px}}
</style>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeftOutlined, BranchesOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import type { Scenario, ScenarioField, SOP } from '@/types'
const route = useRoute()
const router = useRouter()
const scenarioId = Number(route.params.id)
const loading = ref(true)
const scenario = ref<Scenario | null>(null)
const fields = ref<ScenarioField[]>([])
const sops = ref<SOP[]>([])
const fieldOpen = ref(false)
const sopOpen = ref(false)
const fieldForm = reactive({ field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', sort_order: 0 })
const sopForm = reactive({ name: '', description: '' })
const statusColor = computed(() => scenario.value?.status === 'active' ? 'green' : 'default')
async function load() {
loading.value = true
try {
const data = await api.get<{ scenario: Scenario; fields: ScenarioField[]; sops: SOP[] }>(`/scenarios/${scenarioId}`)
scenario.value = data.scenario; fields.value = data.fields; sops.value = data.sops
} catch (error) { message.error(apiMessage(error)) } finally { loading.value = false }
}
async function createField() {
try {
const options = fieldForm.options_text.split('\n').map(v => v.trim()).filter(Boolean)
await api.post(`/scenarios/${scenarioId}/fields`, { ...fieldForm, options, validation: {}, sort_order: fields.value.length })
message.success('字段已添加'); fieldOpen.value = false
Object.assign(fieldForm, { field_key: '', field_name: '', field_type: 'text', required: false, options_text: '', sort_order: 0 }); await load()
} catch (error) { message.error(apiMessage(error)) }
}
async function deleteField(field: ScenarioField) {
Modal.confirm({ title: `删除字段“${field.field_name}”?`, content: '已经使用该字段的 SOP 节点可能需要重新配置。', okType: 'danger', async onOk() { await api.delete(`/scenario-fields/${field.id}`); await load() } })
}
async function createSOP() {
try {
const data = await api.post<{ sop: SOP }>(`/scenarios/${scenarioId}/sops`, sopForm)
message.success('SOP 草稿已创建'); sopOpen.value = false; router.push(`/sops/${data.sop.id}`)
} catch (error) { message.error(apiMessage(error)) }
}
function statusText(value: string) { return ({ draft: '草稿', published: '已发布' } as Record<string,string>)[value] || value }
onMounted(load)
</script>
<template>
<div class="page-shell">
<a-button type="link" class="back-link" @click="router.push('/scenarios')"><ArrowLeftOutlined />返回场景列表</a-button>
<a-skeleton :loading="loading" active>
<div v-if="scenario" class="detail-heading">
<div><div class="heading-tags"><a-tag :color="statusColor">{{ scenario.status === 'active' ? '使用中' : '草稿' }}</a-tag><span>{{ scenario.industry }}</span><span>{{ scenario.role_name }}</span></div><h1>{{ scenario.name }}</h1><p>{{ scenario.goal }}</p></div>
<div class="scenario-code">SCN-{{ String(scenario.id).padStart(4, '0') }}</div>
</div>
<a-tabs default-active-key="fields" class="detail-tabs">
<a-tab-pane key="fields" tab="场景字段">
<section class="surface">
<div class="toolbar"><div><b>采集字段</b><span class="toolbar-note">执行过程中收集的信息</span></div><a-button type="primary" @click="fieldOpen = true"><PlusOutlined />添加字段</a-button></div>
<a-table :data-source="fields" row-key="id" :pagination="false" :columns="[{title:'字段名称',dataIndex:'field_name'},{title:'字段标识',dataIndex:'field_key'},{title:'类型',dataIndex:'field_type',width:130},{title:'必填',dataIndex:'required',width:90},{title:'',key:'action',width:70}]">
<template #bodyCell="{ column, record }"><template v-if="column.dataIndex === 'field_key'"><code>{{ record.field_key }}</code></template><template v-else-if="column.dataIndex === 'required'"><a-tag :color="record.required ? 'orange' : 'default'">{{ record.required ? '必填' : '选填' }}</a-tag></template><template v-else-if="column.key === 'action'"><a-button type="text" danger aria-label="删除字段" @click="deleteField(record)"><DeleteOutlined /></a-button></template></template>
<template #emptyText><div class="empty-copy">还没有字段先添加执行过程中需要收集的客户信息</div></template>
</a-table>
</section>
</a-tab-pane>
<a-tab-pane key="sops" tab="SOP 流程">
<section class="surface">
<div class="toolbar"><div><b>话术流程</b><span class="toolbar-note">一个场景可以包含多套 SOP</span></div><a-button type="primary" @click="sopOpen = true"><PlusOutlined />创建 SOP</a-button></div>
<div v-if="sops.length" class="sop-list">
<button v-for="item in sops" :key="item.id" @click="router.push(`/sops/${item.id}`)"><span class="sop-icon"><BranchesOutlined /></span><span class="sop-copy"><b>{{ item.name }}</b><small>{{ item.description || '暂无说明' }}</small></span><a-tag :color="item.status === 'published' ? 'green' : 'default'">{{ statusText(item.status) }}</a-tag><span class="sop-date">{{ new Date(item.updated_at).toLocaleDateString('zh-CN') }}</span></button>
</div>
<div v-else class="empty-state"><BranchesOutlined /><p>还没有 SOP创建一套流程将经验变成连续动作</p></div>
</section>
</a-tab-pane>
</a-tabs>
</a-skeleton>
<a-modal v-model:open="fieldOpen" title="添加场景字段" ok-text="添加字段" @ok="createField">
<a-form layout="vertical" :model="fieldForm">
<div class="field-grid"><a-form-item label="字段名称"><a-input v-model:value="fieldForm.field_name" placeholder="宠物体重" /></a-form-item><a-form-item label="字段标识"><a-input v-model:value="fieldForm.field_key" placeholder="pet_weight" /></a-form-item></div>
<a-form-item label="字段类型"><a-select v-model:value="fieldForm.field_type" :options="[{value:'text',label:'单行文本'},{value:'textarea',label:'多行文本'},{value:'number',label:'数字'},{value:'boolean',label:'是/否'},{value:'select',label:'单选'},{value:'multiselect',label:'多选'},{value:'date',label:'日期'}]" /></a-form-item>
<a-form-item v-if="['select','multiselect'].includes(fieldForm.field_type)" label="选项(每行一个)"><a-textarea v-model:value="fieldForm.options_text" :rows="4" /></a-form-item>
<a-checkbox v-model:checked="fieldForm.required">执行时必须填写</a-checkbox>
</a-form>
</a-modal>
<a-modal v-model:open="sopOpen" title="创建 SOP 草稿" ok-text="创建并编排" @ok="createSOP">
<a-form layout="vertical" :model="sopForm"><a-form-item label="SOP 名称"><a-input v-model:value="sopForm.name" placeholder="问诊问药标准流程" /></a-form-item><a-form-item label="说明"><a-textarea v-model:value="sopForm.description" :rows="4" placeholder="这套流程适用于什么情况" /></a-form-item></a-form>
</a-modal>
</div>
</template>
<style scoped>
.back-link { margin: -4px 0 12px -14px; color: var(--muted); }.detail-heading { min-height: 150px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; padding: 28px; color: white; background: #202825; border-radius: 6px; }.detail-heading h1 { margin: 10px 0 8px; font-family: "Noto Serif SC",serif; font-size: 28px; letter-spacing: 0; }.detail-heading p { max-width: 720px; margin: 0; color: #b8c4bf; }.heading-tags { display: flex; align-items: center; gap: 10px; color: #a8b6b0; font-size: 12px; }.scenario-code { align-self: flex-start; color: #70817a; font-size: 12px; }.detail-tabs { margin-top: 18px; }.toolbar > div { display: flex; align-items: baseline; gap: 12px; }.toolbar-note { color: var(--muted); font-size: 12px; }.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }code { padding: 2px 5px; color: #17654f; background: #eef5f2; border-radius: 3px; }
.sop-list button { width: 100%; min-height: 74px; display: grid; grid-template-columns: 42px 1fr auto 100px; align-items: center; gap: 14px; padding: 12px 18px; text-align: left; background: white; border: 0; border-bottom: 1px solid var(--line); cursor: pointer; }.sop-list button:hover { background: #f8faf9; }.sop-icon { width: 38px; height: 38px; display: grid; place-items: center; color: var(--green); background: #e8f3ef; border-radius: 4px; }.sop-copy { min-width: 0; display: flex; flex-direction: column; }.sop-copy small { overflow: hidden; margin-top: 4px; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }.sop-date { color: var(--muted); font-size: 12px; text-align: right; }.empty-state { padding: 64px 20px; color: #82908a; text-align: center; }.empty-state > span { font-size: 32px; }.empty-state p { margin: 12px 0 0; }
@media (max-width: 650px) { .detail-heading { align-items: flex-start; flex-direction: column; }.scenario-code { display:none; }.field-grid { grid-template-columns: 1fr; gap: 0; }.sop-list button { grid-template-columns: 36px 1fr auto; }.sop-date { display:none; } }
</style>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { PlusOutlined, SearchOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { api, apiMessage } from '@/api/client'
import type { Scenario } from '@/types'
const router = useRouter()
const loading = ref(false)
const open = ref(false)
const keyword = ref('')
const items = ref<Scenario[]>([])
const form = reactive({ name: '', industry: '', role_name: '', goal: '', trigger_text: '', visibility: 'tenant' })
const columns = [
{ title: '场景名称', dataIndex: 'name', key: 'name' }, { title: '行业', dataIndex: 'industry', key: 'industry', width: 140 },
{ title: '适用角色', dataIndex: 'role_name', key: 'role_name', width: 160 }, { title: '状态', dataIndex: 'status', key: 'status', width: 110 },
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180 }, { title: '', key: 'action', width: 80 },
]
async function load() {
loading.value = true
try { items.value = (await api.get<{ items: Scenario[] }>('/scenarios', { params: { keyword: keyword.value } })).items } finally { loading.value = false }
}
async function create() {
try {
const item = await api.post<Scenario>('/scenarios', form)
message.success('场景已创建')
open.value = false
router.push(`/scenarios/${item.id}`)
} catch (error) { message.error(apiMessage(error)) }
}
function statusText(value: string) { return ({ draft: '草稿', active: '使用中', archived: '已归档' } as Record<string,string>)[value] || value }
onMounted(load)
</script>
<template>
<div class="page-shell">
<div class="page-heading">
<div><h1>场景与 SOP</h1><p>从业务触发点开始定义信息字段与可执行话术流程</p></div>
<a-button type="primary" @click="open = true"><PlusOutlined />新建场景</a-button>
</div>
<section class="surface">
<div class="toolbar">
<a-input v-model:value="keyword" allow-clear placeholder="搜索名称或行业" style="width: 280px" @press-enter="load"><template #prefix><SearchOutlined /></template></a-input>
<span class="muted"> {{ items.length }} 个场景</span>
</div>
<a-table :columns="columns" :data-source="items" :loading="loading" row-key="id" :pagination="false" :scroll="{ x: 800 }" :custom-row="(record: Scenario) => ({ onClick: () => router.push(`/scenarios/${record.id}`) })">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'"><div class="scenario-name"><b>{{ record.name }}</b><small>{{ record.goal }}</small></div></template>
<template v-else-if="column.key === 'status'"><a-tag :color="record.status === 'active' ? 'green' : 'default'">{{ statusText(record.status) }}</a-tag></template>
<template v-else-if="column.key === 'updated_at'">{{ new Date(record.updated_at).toLocaleString('zh-CN') }}</template>
<template v-else-if="column.key === 'action'"><a-button type="link" size="small">配置</a-button></template>
</template>
<template #emptyText><div class="empty-copy">还没有业务场景。创建第一个场景后,就可以配置字段和 SOP。</div></template>
</a-table>
</section>
<a-drawer v-model:open="open" title="创建业务场景" width="520" :destroy-on-close="true">
<a-form layout="vertical" :model="form">
<a-form-item label="场景名称" name="name" :rules="[{ required: true, message: '请输入场景名称' }]"><a-input v-model:value="form.name" placeholder="例如宠物医生问诊问药" /></a-form-item>
<div class="form-grid"><a-form-item label="所属行业" name="industry" :rules="[{ required: true, message: '请输入行业' }]"><a-input v-model:value="form.industry" placeholder="宠物医疗" /></a-form-item><a-form-item label="适用角色" name="role_name" :rules="[{ required: true, message: '请输入角色' }]"><a-input v-model:value="form.role_name" placeholder="医生客服" /></a-form-item></div>
<a-form-item label="执行目标" name="goal" :rules="[{ required: true, message: '请输入执行目标' }]"><a-textarea v-model:value="form.goal" :rows="3" placeholder="这个场景最终要推动什么结果" /></a-form-item>
<a-form-item label="触发条件" name="trigger_text" :rules="[{ required: true, message: '请输入触发条件' }]"><a-textarea v-model:value="form.trigger_text" :rows="3" placeholder="什么时候进入这个场景" /></a-form-item>
<a-form-item label="可见范围"><a-radio-group v-model:value="form.visibility"><a-radio value="tenant">全企业</a-radio><a-radio value="team">团队</a-radio><a-radio value="private">仅自己</a-radio></a-radio-group></a-form-item>
<a-button type="primary" block @click="create">创建并继续配置</a-button>
</a-form>
</a-drawer>
</div>
</template>
<style scoped>
.scenario-name { max-width: 460px; display: flex; flex-direction: column; cursor: pointer; }.scenario-name b { font-size: 14px; }.scenario-name small { overflow: hidden; margin-top: 4px; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
:deep(.ant-table-row) { cursor: pointer; }
@media (max-width: 600px) { .form-grid { grid-template-columns: 1fr; gap: 0; } }
</style>