feat: complete pet SOP governance workflow
This commit is contained in:
@@ -1,8 +1,27 @@
|
||||
import axios, { type AxiosRequestConfig } from 'axios'
|
||||
import axios, { type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
interface Envelope<T> { code: string; message: string; data: T }
|
||||
|
||||
const client = axios.create({ baseURL: '/api/v1', timeout: 15000 })
|
||||
let refreshing: Promise<string> | null = null
|
||||
|
||||
interface RetryConfig extends InternalAxiosRequestConfig { _retry?: boolean }
|
||||
|
||||
function clearSession() {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
if (window.location.pathname !== '/login') window.location.href = '/login'
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (!refreshToken) throw new Error('refresh token is missing')
|
||||
const response = await axios.post<Envelope<{ access_token:string; refresh_token:string }>>('/api/v1/auth/refresh', { refresh_token: refreshToken })
|
||||
const pair = response.data.data
|
||||
localStorage.setItem('access_token', pair.access_token)
|
||||
localStorage.setItem('refresh_token', pair.refresh_token)
|
||||
return pair.access_token
|
||||
}
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
@@ -12,11 +31,20 @@ client.interceptors.request.use((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'
|
||||
async (error) => {
|
||||
const original = error.config as RetryConfig | undefined
|
||||
const authRequest = original?.url?.includes('/auth/login') || original?.url?.includes('/auth/refresh')
|
||||
if (error.response?.status === 401 && original && !original._retry && !authRequest) {
|
||||
original._retry = true
|
||||
try {
|
||||
refreshing ||= refreshAccessToken().finally(() => { refreshing = null })
|
||||
const token = await refreshing
|
||||
original.headers.Authorization = `Bearer ${token}`
|
||||
return client(original)
|
||||
} catch (refreshError) {
|
||||
clearSession()
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AuditOutlined,
|
||||
BookOutlined,
|
||||
DashboardOutlined,
|
||||
HistoryOutlined,
|
||||
@@ -11,9 +12,11 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PlayCircleOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const collapsed = ref(false)
|
||||
const mobileOpen = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -22,19 +25,23 @@ 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('/reviews')) return ['reviews']
|
||||
if (route.path.startsWith('/members')) return ['members']
|
||||
if (route.path.startsWith('/knowledge')) return ['knowledge']
|
||||
return ['dashboard']
|
||||
})
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
dashboard: '工作台', scenarios: '场景与 SOP', execute: '执行话术', runs: '执行记录', knowledge: '知识卡',
|
||||
dashboard: '工作台', scenarios: '场景与 SOP', reviews: '审核中心', execute: '执行话术', runs: '执行记录', knowledge: '知识卡', members: '企业成员',
|
||||
}
|
||||
const pageTitle = computed(() => titles[selectedKeys.value[0]] || '销冠 SOP')
|
||||
const roleText = computed(() => ({admin:'企业管理员',editor:'SOP 编辑者',reviewer:'审核者',operator:'一线执行者'}[auth.user?.role_code||'']||auth.user?.role_code||''))
|
||||
|
||||
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' }
|
||||
const target: Record<string, string> = { dashboard: '/', scenarios: '/scenarios', reviews: '/reviews', execute: '/execute', runs: '/runs', knowledge: '/knowledge', members: '/members' }
|
||||
mobileOpen.value = false
|
||||
router.push(target[key])
|
||||
}
|
||||
|
||||
@@ -56,10 +63,12 @@ function logout() {
|
||||
</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-item v-if="auth.can('scenario.view')" key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('sop.review')" key="reviews"><AuditOutlined /><span>审核中心</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('sop.execute')" key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('knowledge.view')" key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('member.manage')" key="members"><TeamOutlined /><span>企业成员</span></a-menu-item>
|
||||
</a-menu>
|
||||
<div class="sider-foot" :class="{ compact: collapsed }">
|
||||
<span class="online-dot"></span>
|
||||
@@ -69,16 +78,19 @@ function logout() {
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="topbar">
|
||||
<a-button type="text" class="collapse-button" :aria-label="collapsed ? '展开导航' : '收起导航'" @click="collapsed = !collapsed">
|
||||
<a-button type="text" class="collapse-button desktop-nav-toggle" :aria-label="collapsed ? '展开导航' : '收起导航'" @click="collapsed = !collapsed">
|
||||
<MenuUnfoldOutlined v-if="collapsed" /><MenuFoldOutlined v-else />
|
||||
</a-button>
|
||||
<a-button type="text" class="collapse-button mobile-nav-toggle" aria-label="打开导航" @click="mobileOpen = true">
|
||||
<MenuUnfoldOutlined />
|
||||
</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>
|
||||
<small>{{ roleText }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<template #overlay>
|
||||
@@ -88,6 +100,22 @@ function logout() {
|
||||
</a-layout-header>
|
||||
<a-layout-content class="content-area"><router-view /></a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<a-drawer v-model:open="mobileOpen" placement="left" :width="280" :closable="false" class="mobile-nav-drawer">
|
||||
<div class="mobile-brand">
|
||||
<div class="brand-mark"><span></span><span></span><span></span></div>
|
||||
<div 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 v-if="auth.can('scenario.view')" key="scenarios"><AppstoreOutlined /><span>场景与 SOP</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('sop.review')" key="reviews"><AuditOutlined /><span>审核中心</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('sop.execute')" key="execute"><PlayCircleOutlined /><span>执行话术</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" key="runs"><HistoryOutlined /><span>执行记录</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('knowledge.view')" key="knowledge"><BookOutlined /><span>知识卡</span></a-menu-item>
|
||||
<a-menu-item v-if="auth.can('member.manage')" key="members"><TeamOutlined /><span>企业成员</span></a-menu-item>
|
||||
</a-menu>
|
||||
</a-drawer>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
@@ -110,6 +138,7 @@ function logout() {
|
||||
.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; }
|
||||
.mobile-nav-toggle { display: none; }
|
||||
.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; }
|
||||
@@ -117,5 +146,10 @@ function logout() {
|
||||
.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; } }
|
||||
:global(.mobile-nav-drawer .ant-drawer-body) { padding: 0; background: #202825; }
|
||||
.mobile-brand { height: 78px; display: flex; align-items: center; gap: 12px; padding: 0 20px; border-bottom: 1px solid rgba(255,255,255,.09); }
|
||||
.mobile-nav-drawer :deep(.ant-menu-dark) { min-height: calc(100vh - 78px); padding: 12px 9px; background: #202825; }
|
||||
.mobile-nav-drawer :deep(.ant-menu-item) { height: 42px; margin: 5px 0; border-radius: 4px; color: #b9c4c0; }
|
||||
.mobile-nav-drawer :deep(.ant-menu-item-selected) { background: #166b53 !important; color: white; }
|
||||
@media (max-width: 760px) { .side-panel { display: none; }.desktop-nav-toggle { display: none; }.mobile-nav-toggle { display: inline-flex; }.user-copy { display: none; }.topbar { padding: 0 12px; } }
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,9 @@ const router = createRouter({
|
||||
{ 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: 'runs/:id', name: 'run-detail', component: () => import('@/views/RunDetailView.vue') },
|
||||
{ path: 'reviews', name: 'reviews', component: () => import('@/views/ReviewCenterView.vue') },
|
||||
{ path: 'members', name: 'members', component: () => import('@/views/MembersView.vue') },
|
||||
{ path: 'knowledge', name: 'knowledge', component: () => import('@/views/KnowledgeView.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ interface LoginResult { access_token: string; refresh_token: string; expires_at:
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const token = ref(localStorage.getItem('access_token') || '')
|
||||
const loaded = ref(false)
|
||||
const authenticated = computed(() => Boolean(token.value))
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
@@ -16,19 +17,26 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
user.value = result.user
|
||||
localStorage.setItem('access_token', result.access_token)
|
||||
localStorage.setItem('refresh_token', result.refresh_token)
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function loadUser() {
|
||||
if (!token.value) return
|
||||
user.value = await api.get<User>('/auth/me')
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
loaded.value = false
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
}
|
||||
|
||||
return { user, token, authenticated, login, loadUser, logout }
|
||||
function can(permission: string) {
|
||||
return Boolean(user.value?.permissions?.includes('*') || user.value?.permissions?.includes(permission))
|
||||
}
|
||||
|
||||
return { user, token, loaded, authenticated, login, loadUser, logout, can }
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 User { user_id: number; tenant_id: number; role_code: string; permissions: 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 }
|
||||
@@ -8,3 +8,9 @@ export interface SOPNode extends BaseEntity { node_key: string; type: string; ti
|
||||
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> }
|
||||
export interface ReviewItem { sop_id:number; sop_name:string; description:string; scenario_id:number; scenario_name:string; version_id:number; version:number; creator_id:number; creator_name:string; submitted_at:string }
|
||||
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string }
|
||||
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
|
||||
export interface RunDetail extends SOPRun { scenario_name:string; version:number; operator_name:string }
|
||||
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }
|
||||
export interface Member { id:number; user_id:number; username:string; display_name:string; role_code:string; role_name:string; status:string }
|
||||
|
||||
@@ -3,8 +3,10 @@ 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'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const summary = ref({ scenarios: 0, published_sops: 0, runs: 0, completed_runs: 0 })
|
||||
|
||||
@@ -17,7 +19,7 @@ onMounted(async () => {
|
||||
<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 v-if="auth.can('scenario.edit')" class="page-actions"><a-button type="primary" @click="router.push('/scenarios')"><PlusOutlined />创建场景</a-button></div>
|
||||
</div>
|
||||
|
||||
<a-skeleton :loading="loading" active>
|
||||
@@ -32,9 +34,11 @@ onMounted(async () => {
|
||||
<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>
|
||||
<button v-if="auth.can('scenario.edit')" @click="router.push('/scenarios')"><span>配置新的业务场景<small>定义字段、目标和触发条件</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('sop.review')" @click="router.push('/reviews')"><span>处理待审核 SOP<small>核对流程、口径和风险出口</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('sop.execute')" @click="router.push('/execute')"><span>开始执行已发布 SOP<small>根据客户回答逐步推进</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('knowledge.edit')" @click="router.push('/knowledge')"><span>维护审核知识卡<small>统一口径与风险提示</small></span><ArrowRightOutlined /></button>
|
||||
<button v-if="auth.can('runs.view_all')||auth.can('runs.view_own')" @click="router.push('/runs')"><span>复盘执行记录<small>查看节点轨迹、回答与反馈</small></span><ArrowRightOutlined /></button>
|
||||
</div>
|
||||
<div class="surface doctrine-panel">
|
||||
<div class="panel-kicker">平台原则</div>
|
||||
|
||||
@@ -8,12 +8,14 @@ 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 feedback=reactive({score:5,comment:''});const feedbackSent=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 start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k]);Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}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`,{node_key:active.value.node.node_key,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}
|
||||
async function sendFeedback(){if(!active.value)return;submitting.value=true;try{await api.post(`/runs/${active.value.run.id}/feedback`,feedback);feedbackSent.value=true;message.success('反馈已提交')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
function reset(){active.value=null;load()}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -36,7 +38,7 @@ onMounted(load)
|
||||
<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>
|
||||
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.node.content }}</p><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @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">
|
||||
@@ -60,6 +62,6 @@ onMounted(load)
|
||||
</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}}
|
||||
.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)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
|
||||
@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}.quick-feedback{grid-template-columns:1fr}.quick-feedback :deep(.ant-input){grid-column:auto}}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,10 @@ 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 { useAuthStore } from '@/stores/auth'
|
||||
import type { KnowledgeCard, Scenario } from '@/types'
|
||||
const loading=ref(false);const open=ref(false);const items=ref<KnowledgeCard[]>([]);const scenarios=ref<Scenario[]>([])
|
||||
const auth=useAuthStore()
|
||||
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}}
|
||||
@@ -13,5 +15,5 @@ function remove(item:KnowledgeCard){Modal.confirm({title:`归档“${item.title}
|
||||
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>
|
||||
<template><div class="page-shell"><div class="page-heading"><div><h1>知识卡</h1><p>沉淀经过审核的标准表达、禁用语和风险提醒。</p></div><a-button v-if="auth.can('knowledge.edit')" 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 v-if="auth.can('knowledge.edit')" type="text" danger aria-label="归档知识卡" @click="remove(item)"><DeleteOutlined /></a-button></footer></article></div><a-empty v-else description="还没有知识卡" /></a-spin><a-drawer v-if="auth.can('knowledge.edit')" 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>
|
||||
|
||||
33
web/src/views/MembersView.vue
Normal file
33
web/src/views/MembersView.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { EditOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Member, Role } from '@/types'
|
||||
|
||||
const auth=useAuthStore();const loading=ref(false);const open=ref(false);const editing=ref<Member|null>(null);const items=ref<Member[]>([]);const roles=ref<Role[]>([]);const saving=ref(false)
|
||||
const form=reactive({username:'',display_name:'',password:'',role_code:'operator'})
|
||||
const editForm=reactive({role_code:'operator',status:'active'})
|
||||
const roleOptions=computed(()=>roles.value.map(role=>({value:role.code,label:role.name})))
|
||||
const columns=[{title:'成员',key:'member'},{title:'角色',dataIndex:'role_name',width:150},{title:'状态',dataIndex:'status',width:110},{title:'',key:'action',width:80}]
|
||||
async function load(){loading.value=true;try{const data=await api.get<{items:Member[];roles:Role[]}>('/members');items.value=data.items||[];roles.value=data.roles||[]}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
async function create(){saving.value=true;try{await api.post('/members',form);message.success('成员已创建');open.value=false;Object.assign(form,{username:'',display_name:'',password:'',role_code:'operator'});await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
|
||||
function edit(item:Member){editing.value=item;Object.assign(editForm,{role_code:item.role_code,status:item.status})}
|
||||
async function update(){if(!editing.value)return;saving.value=true;try{await api.put(`/members/${editing.value.id}`,editForm);message.success('成员权限已更新');editing.value=null;await load()}catch(error){message.error(apiMessage(error))}finally{saving.value=false}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell members-page">
|
||||
<div class="page-heading"><div><h1>企业成员</h1><p>分配 SOP 编辑、审核和一线执行职责,权限变更会立即生效。</p></div><a-button type="primary" @click="open=true"><PlusOutlined />添加成员</a-button></div>
|
||||
<section class="member-summary"><TeamOutlined /><div><b>{{ items.filter(item=>item.status==='active').length }}</b><span>在用账号</span></div><p>管理员拥有全部权限;编辑者维护场景与草稿;审核者负责发布;执行者只使用已发布 SOP 和自己的记录。</p></section>
|
||||
<section class="surface"><a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="false"><template #bodyCell="{column,record}"><template v-if="column.key==='member'"><div class="member-cell"><span>{{ record.display_name.slice(0,1) }}</span><div><b>{{ record.display_name }}</b><small>{{ record.username }}</small></div></div></template><template v-else-if="column.dataIndex==='role_name'"><a-tag>{{ record.role_name }}</a-tag></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='active'?'green':'default'">{{ record.status==='active'?'使用中':'已停用' }}</a-tag></template><template v-else-if="column.key==='action'"><a-button v-if="record.user_id!==auth.user?.user_id" type="text" aria-label="编辑成员权限" @click="edit(record)"><EditOutlined /></a-button></template></template></a-table></section>
|
||||
<a-drawer v-model:open="open" title="添加企业成员" width="480"><a-form layout="vertical" :model="form" @finish="create"><div class="form-grid"><a-form-item label="登录用户名" name="username" :rules="[{required:true,message:'请输入用户名'}]"><a-input v-model:value="form.username" /></a-form-item><a-form-item label="显示名称" name="display_name" :rules="[{required:true,message:'请输入显示名称'}]"><a-input v-model:value="form.display_name" /></a-form-item></div><a-form-item label="初始密码" name="password" :rules="[{required:true,min:6,message:'密码至少 6 位'}]"><a-input-password v-model:value="form.password" /></a-form-item><a-form-item label="角色" name="role_code" :rules="[{required:true}]"><a-select v-model:value="form.role_code" :options="roleOptions" /></a-form-item><a-button type="primary" html-type="submit" block :loading="saving">创建成员</a-button></a-form></a-drawer>
|
||||
<a-modal :open="Boolean(editing)" title="调整成员权限" ok-text="保存" :confirm-loading="saving" @ok="update" @cancel="editing=null"><div v-if="editing" class="editing-name"><b>{{ editing.display_name }}</b><span>{{ editing.username }}</span></div><a-form layout="vertical"><a-form-item label="角色"><a-select v-model:value="editForm.role_code" :options="roleOptions" /></a-form-item><a-form-item label="账号状态"><a-segmented v-model:value="editForm.status" :options="[{value:'active',label:'使用中'},{value:'disabled',label:'停用'}]" /></a-form-item></a-form></a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.members-page{max-width:1180px}.member-summary{min-height:86px;display:grid;grid-template-columns:38px 90px 1fr;align-items:center;gap:14px;margin-bottom:16px;padding:16px 20px;color:white;background:#202825;border-radius:6px}.member-summary>span{font-size:25px;color:#6bc5a7}.member-summary>div{display:flex;flex-direction:column}.member-summary b{font-family:"Noto Serif SC",serif;font-size:24px}.member-summary div span{color:#a9b8b2;font-size:11px}.member-summary p{margin:0;color:#b6c3be;line-height:1.7}.member-cell{display:flex;align-items:center;gap:11px}.member-cell>span{width:34px;height:34px;display:grid;place-items:center;color:#17664f;background:#e5f2ed;border-radius:4px;font-weight:700}.member-cell>div{display:flex;flex-direction:column}.member-cell small{margin-top:3px;color:var(--muted)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.editing-name{display:flex;flex-direction:column;margin-bottom:18px;padding:12px 14px;background:#f4f7f5;border-left:3px solid var(--green)}.editing-name span{margin-top:4px;color:var(--muted);font-size:12px}@media(max-width:650px){.member-summary{grid-template-columns:34px 1fr}.member-summary p{grid-column:1/3}.form-grid{grid-template-columns:1fr;gap:0}}
|
||||
</style>
|
||||
35
web/src/views/ReviewCenterView.vue
Normal file
35
web/src/views/ReviewCenterView.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { CheckOutlined, ClockCircleOutlined, EyeOutlined, StopOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import type { ReviewItem } from '@/types'
|
||||
|
||||
const router=useRouter();const loading=ref(false);const items=ref<ReviewItem[]>([]);const rejecting=ref<ReviewItem|null>(null);const submitting=ref(false);const rejectForm=reactive({reason:''})
|
||||
const columns=[{title:'SOP / 场景',key:'sop'},{title:'版本',dataIndex:'version',width:90},{title:'提交人',dataIndex:'creator_name',width:140},{title:'等待时间',dataIndex:'submitted_at',width:180},{title:'',key:'action',width:250}]
|
||||
async function load(){loading.value=true;try{const data=await api.get<{items:ReviewItem[]}>('/reviews');items.value=data.items||[]}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
async function approve(item:ReviewItem){submitting.value=true;try{await api.post(`/sops/${item.sop_id}/publish`);message.success(`“${item.sop_name}”已通过并发布`);await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
function openReject(item:ReviewItem){rejecting.value=item;rejectForm.reason=''}
|
||||
async function reject(){if(!rejecting.value||!rejectForm.reason.trim())return message.warning('请填写退回原因');submitting.value=true;try{await api.post(`/sops/${rejecting.value.sop_id}/reject`,{reason:rejectForm.reason});message.success('已退回编辑者修改');rejecting.value=null;await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
function waiting(value:string){const minutes=Math.max(1,Math.round((Date.now()-new Date(value).getTime())/60000));if(minutes<60)return`${minutes} 分钟`;const hours=Math.round(minutes/60);return hours<48?`${hours} 小时`:`${Math.round(hours/24)} 天`}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell review-page">
|
||||
<div class="page-heading"><div><h1>审核中心</h1><p>检查流程完整性、风险分支和话术口径后再决定发布。</p></div><div class="queue-count"><ClockCircleOutlined /><b>{{ items.length }}</b><span>待审核</span></div></div>
|
||||
<section class="surface review-table">
|
||||
<div class="review-guideline"><span>审核关注</span><b>字段完整</b><i></i><b>条件可达</b><i></i><b>高风险有出口</b><i></i><b>知识卡已发布</b></div>
|
||||
<a-table row-key="version_id" :loading="loading" :data-source="items" :columns="columns" :pagination="false" :scroll="{x:850}">
|
||||
<template #bodyCell="{column,record}"><template v-if="column.key==='sop'"><div class="sop-cell"><b>{{ record.sop_name }}</b><span>{{ record.scenario_name }} · {{ record.description||'暂无说明' }}</span></div></template><template v-else-if="column.dataIndex==='version'"><a-tag>V{{ record.version }}</a-tag></template><template v-else-if="column.dataIndex==='submitted_at'"><span class="waiting">{{ waiting(record.submitted_at) }}</span></template><template v-else-if="column.key==='action'"><div class="row-actions"><a-button @click="router.push(`/sops/${record.sop_id}`)"><EyeOutlined />查看</a-button><a-button danger @click="openReject(record)"><StopOutlined />退回</a-button><a-button type="primary" :loading="submitting" @click="approve(record)"><CheckOutlined />通过</a-button></div></template></template>
|
||||
<template #emptyText><div class="review-empty"><CheckOutlined /><b>审核队列已清空</b><span>新提交的 SOP 会出现在这里。</span></div></template>
|
||||
</a-table>
|
||||
</section>
|
||||
<a-modal :open="Boolean(rejecting)" title="退回修改" ok-text="确认退回" :confirm-loading="submitting" @ok="reject" @cancel="rejecting=null"><p class="modal-copy">请说明“{{ rejecting?.sop_name }}”需要调整的具体内容,编辑者可据此修改后重新提交。</p><a-textarea v-model:value="rejectForm.reason" :rows="5" :maxlength="1000" show-count placeholder="例如:急症判断缺少默认路径,请补充非急症分支。" /></a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.queue-count{height:54px;display:grid;grid-template-columns:24px 32px auto;align-items:center;gap:8px;padding:0 16px;color:#3f4c46;background:#fff;border:1px solid var(--line);border-radius:6px}.queue-count>span:first-child{color:var(--amber)}.queue-count b{font-family:"Noto Serif SC",serif;font-size:24px}.queue-count span:last-child{color:var(--muted);font-size:12px}.review-table{overflow:hidden}.review-guideline{min-height:48px;display:flex;align-items:center;gap:12px;padding:0 16px;color:#637069;background:#f7f9f8;border-bottom:1px solid var(--line);font-size:11px}.review-guideline>span{font-weight:750;color:#28332e}.review-guideline i{width:3px;height:3px;border-radius:50%;background:#a7b2ad}.sop-cell{display:flex;flex-direction:column;max-width:520px}.sop-cell b{font-size:14px}.sop-cell span{margin-top:5px;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.waiting{color:#8a641e}.row-actions{display:flex;justify-content:flex-end;gap:7px}.review-empty{min-height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#9aa59f}.review-empty>span:first-child{font-size:34px;color:#4eaa88}.review-empty b{margin-top:12px;color:#415049}.review-empty span:last-child{margin-top:5px;font-size:12px}.modal-copy{margin:0 0 14px;color:var(--muted);line-height:1.7}@media(max-width:650px){.review-guideline{display:none}.queue-count{display:none}}
|
||||
</style>
|
||||
65
web/src/views/RunDetailView.vue
Normal file
65
web/src/views/RunDetailView.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { RunDetail, RunEvent, RunFeedback, ScenarioField } from '@/types'
|
||||
|
||||
interface DetailResponse{run:RunDetail;events:RunEvent[];feedback:RunFeedback[];fields:ScenarioField[]}
|
||||
const route=useRoute();const router=useRouter();const auth=useAuthStore();const loading=ref(true);const detail=ref<DetailResponse|null>(null);const submitting=ref(false);const feedback=reactive({score:5,comment:''})
|
||||
const fieldMap=computed(()=>new Map((detail.value?.fields||[]).map(field=>[field.field_key,field.field_name])))
|
||||
const answers=computed(()=>Object.entries(detail.value?.run.answers||{}))
|
||||
const canFeedback=computed(()=>auth.can('runs.feedback')&&detail.value?.run.status==='completed'&&!detail.value.feedback.some(item=>item.user_id===auth.user?.user_id))
|
||||
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'进行中'}
|
||||
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答'}[v]||v}
|
||||
function valueText(value:any){if(value===true)return'是';if(value===false)return'否';if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return'-';return String(value)}
|
||||
function answerEntries(event:RunEvent){const values=event.payload?.answers||{};return Object.entries(values)}
|
||||
async function load(){loading.value=true;try{detail.value=await api.get<DetailResponse>(`/runs/${route.params.id}/detail`)}catch(error){message.error(apiMessage(error));router.replace('/runs')}finally{loading.value=false}}
|
||||
async function submitFeedback(){submitting.value=true;try{await api.post(`/runs/${route.params.id}/feedback`,feedback);message.success('反馈已提交');await load()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell run-detail-page">
|
||||
<a-button type="link" class="back-link" @click="router.push('/runs')"><ArrowLeftOutlined />返回执行记录</a-button>
|
||||
<a-spin :spinning="loading">
|
||||
<template v-if="detail">
|
||||
<header class="detail-heading">
|
||||
<div><div class="run-code">RUN-{{ String(detail.run.id).padStart(5,'0') }}</div><h1>{{ detail.run.sop_name }}</h1><p>{{ detail.run.scenario_name }} · V{{ detail.run.version }} · {{ detail.run.operator_name }}</p></div>
|
||||
<div class="result-block" :class="detail.run.result==='escalate'?'warning':'success'"><SafetyCertificateOutlined v-if="detail.run.result==='escalate'"/><CheckCircleOutlined v-else/><span><small>{{ detail.run.status==='completed'?'执行结果':'当前状态' }}</small><b>{{ resultText(detail.run.result) }}</b></span></div>
|
||||
</header>
|
||||
|
||||
<section class="detail-layout">
|
||||
<main class="surface timeline-panel">
|
||||
<div class="section-title"><span>执行轨迹</span><small>按实际发生顺序记录</small></div>
|
||||
<a-timeline class="run-timeline">
|
||||
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='answer'?'blue':event.node_type==='escalate'?'red':'green'">
|
||||
<div class="event-head"><b>{{ event.node_title||event.node_key }}</b><a-tag>{{ actionText(event.action) }}</a-tag><time>{{ new Date(event.created_at).toLocaleString('zh-CN') }}</time></div>
|
||||
<p v-if="event.action==='enter'&&event.node_content">{{ event.node_content }}</p>
|
||||
<div v-if="answerEntries(event).length" class="event-answers"><span v-for="[key,value] in answerEntries(event)" :key="key"><small>{{ fieldMap.get(key)||key }}</small><b>{{ valueText(value) }}</b></span></div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</main>
|
||||
|
||||
<aside class="detail-aside">
|
||||
<section class="surface summary-panel"><div class="section-title"><span>执行信息</span></div><dl><div><dt>状态</dt><dd><a-tag :color="detail.run.status==='completed'?'green':'orange'">{{ detail.run.status==='completed'?'已完成':'进行中' }}</a-tag></dd></div><div><dt>开始时间</dt><dd>{{ new Date(detail.run.started_at).toLocaleString('zh-CN') }}</dd></div><div><dt>完成时间</dt><dd>{{ detail.run.completed_at?new Date(detail.run.completed_at).toLocaleString('zh-CN'):'-' }}</dd></div><div><dt>当前节点</dt><dd>{{ detail.run.current_node_key }}</dd></div></dl></section>
|
||||
<section class="surface answer-panel"><div class="section-title"><span>已采集信息</span><small>{{ answers.length }} 项</small></div><div v-if="answers.length" class="answer-list"><div v-for="[key,value] in answers" :key="key"><span>{{ fieldMap.get(key)||key }}</span><b>{{ valueText(value) }}</b></div></div><div v-else class="aside-empty">尚未采集字段</div></section>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="surface feedback-band">
|
||||
<div class="section-title"><span>执行反馈</span><small>用于判断 SOP 是否清晰、有效</small></div>
|
||||
<div v-if="detail.feedback.length" class="feedback-list"><article v-for="item in detail.feedback" :key="item.id"><div><b>{{ item.user_name }}</b><a-rate :value="item.score" disabled /></div><p>{{ item.comment||'未填写补充说明' }}</p><time>{{ new Date(item.created_at).toLocaleString('zh-CN') }}</time></article></div>
|
||||
<div v-if="canFeedback" class="feedback-form"><div><span>本次 SOP 是否好用?</span><a-rate v-model:value="feedback.score" /></div><a-textarea v-model:value="feedback.comment" :rows="3" :maxlength="2000" show-count placeholder="记录不顺畅的步骤、缺失信息或有效话术。"/><a-button type="primary" :loading="submitting" @click="submitFeedback">提交反馈</a-button></div>
|
||||
<div v-if="!detail.feedback.length&&!canFeedback" class="aside-empty">暂无反馈</div>
|
||||
</section>
|
||||
</template>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;text-align:right;font-size:12px}.answer-list>div{display:flex;flex-direction:column;padding:10px 0;border-bottom:1px solid #edf0ee}.answer-list>div:last-child{border-bottom:0}.answer-list span{color:var(--muted);font-size:11px}.answer-list b{margin-top:4px;font-size:13px}.aside-empty{padding:24px 0;color:#929d98;text-align:center;font-size:12px}.feedback-band{margin-top:16px;padding:22px 24px}.feedback-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.feedback-list article{padding:14px 0;border-top:1px solid var(--line)}.feedback-list article>div{display:flex;align-items:center;justify-content:space-between}.feedback-list :deep(.ant-rate){font-size:15px}.feedback-list p{margin:9px 0;color:#54615b}.feedback-list time{color:#929d98;font-size:11px}.feedback-form{display:grid;grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;border-top:1px solid var(--line)}.feedback-form>div{display:flex;flex-direction:column;gap:6px}.feedback-form>div>span{font-size:12px;font-weight:650}.feedback-form :deep(.ant-rate){font-size:20px}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-aside{display:grid;grid-template-columns:1fr 1fr}.feedback-form{grid-template-columns:1fr}.feedback-list{grid-template-columns:1fr}}@media(max-width:620px){.detail-heading{align-items:flex-start;flex-direction:column}.result-block{width:100%}.detail-aside{grid-template-columns:1fr}.timeline-panel{padding:18px}.event-head{align-items:flex-start;flex-wrap:wrap}.event-head time{width:100%;margin:0}.event-answers{grid-template-columns:1fr}}
|
||||
</style>
|
||||
@@ -1,12 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { FilterOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
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}}
|
||||
|
||||
const router=useRouter()
|
||||
const loading=ref(false);const items=ref<SOPRun[]>([]);const total=ref(0);const page=ref(1);const pageSize=ref(20)
|
||||
const filters=reactive({status:'',result:''})
|
||||
const columns=[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP',dataIndex:'sop_name'},{title:'状态',dataIndex:'status',width:110},{title:'结果',dataIndex:'result',width:130},{title:'开始时间',dataIndex:'started_at',width:190},{title:'完成时间',dataIndex:'completed_at',width:190},{title:'',key:'action',width:54}]
|
||||
async function load(reset=false){if(reset)page.value=1;loading.value=true;try{const data=await api.get<{items:SOPRun[];total:number}>('/runs',{params:{page:page.value,page_size:pageSize.value,status:filters.status||undefined,result:filters.result||undefined}});items.value=data.items||[];total.value=data.total}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
function statusText(v:string){return v==='completed'?'已完成':'进行中'}
|
||||
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'-'}
|
||||
function changePage(next:number,size:number){page.value=next;pageSize.value=size;load()}
|
||||
function rowProps(record:SOPRun){return{onClick:()=>router.push(`/runs/${record.id}`)}}
|
||||
function tableChange(pager:{current?:number;pageSize?:number}){changePage(pager.current||1,pager.pageSize||20)}
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="page-heading"><div><h1>执行记录</h1><p>按结果筛选实际执行,进入详情复盘每一步回答和分流。</p></div></div>
|
||||
<section class="surface">
|
||||
<div class="history-toolbar">
|
||||
<div class="filter-set"><FilterOutlined /><a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="[{value:'running',label:'进行中'},{value:'completed',label:'已完成'}]" @change="load(true)"/><a-select v-model:value="filters.result" allow-clear placeholder="全部结果" :options="[{value:'finish',label:'正常结束'},{value:'escalate',label:'转人工 / 转诊'},{value:'manual',label:'人工结束'}]" @change="load(true)"/></div>
|
||||
<a-button type="text" aria-label="刷新执行记录" @click="load()"><ReloadOutlined /></a-button>
|
||||
</div>
|
||||
<a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="{current:page,pageSize,total,showSizeChanger:true,showTotal:(value:number)=>`共 ${value} 条`}" :scroll="{x:980}" :custom-row="rowProps" @change="tableChange">
|
||||
<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'">{{ resultText(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 v-else-if="column.key==='action'"><RightOutlined class="row-arrow" /></template></template>
|
||||
<template #emptyText><div class="empty-copy">当前筛选条件下没有执行记录。</div></template>
|
||||
</a-table>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.filter-set{display:flex;align-items:center;gap:9px;color:#7a8781}.filter-set :deep(.ant-select){width:160px}.surface :deep(.ant-table-row){cursor:pointer}.surface :deep(.ant-table-row:hover) .row-arrow{color:var(--green);transform:translateX(2px)}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.row-arrow{color:#9ba6a1;transition:all .16s ease}@media(max-width:600px){.history-toolbar{align-items:flex-start}.filter-set{flex-wrap:wrap}.filter-set :deep(.ant-select){width:138px}}
|
||||
</style>
|
||||
|
||||
@@ -4,17 +4,20 @@ 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 { useAuthStore } from '@/stores/auth'
|
||||
import type { ScenarioField, SOP, SOPEdge, SOPNode, SOPVersion } from '@/types'
|
||||
|
||||
interface KnowledgeCard { id:number; scenario_id:number; title:string }
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const id = Number(route.params.id)
|
||||
const auth = useAuthStore()
|
||||
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 fields = ref<ScenarioField[]>([]); const knowledgeCards = ref<KnowledgeCard[]>([])
|
||||
const selected = computed(() => nodes.value.find(n => n.node_key === selectedKey.value))
|
||||
const editable = computed(() => version.value?.status === 'draft')
|
||||
const canEdit = computed(() => editable.value&&auth.can('sop.edit'))
|
||||
const reviewing = computed(() => version.value?.status === 'reviewing')
|
||||
const nodeTypes = [{value:'message',label:'标准话术'},{value:'question',label:'单项提问'},{value:'form',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: '' })
|
||||
@@ -47,7 +50,7 @@ function conditionText(condition:Record<string,any>):string{if(!condition||Objec
|
||||
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 publish(){try{if(canEdit.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)
|
||||
@@ -57,29 +60,29 @@ onMounted(load)
|
||||
<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>
|
||||
<div class="page-actions"><a-button @click="validate">校验流程</a-button><a-button v-if="canEdit" :loading="saving" @click="save"><SaveOutlined />保存</a-button><a-button v-if="canEdit&&auth.can('sop.submit_review')" @click="submitReview"><SendOutlined />提交审核</a-button><a-button v-if="auth.can('sop.publish')&&(reviewing||(editable&&auth.can('*')))" type="primary" @click="publish"><SendOutlined />发布</a-button><a-button v-if="version?.status === 'published'&&auth.can('sop.publish')" danger @click="offline">下线</a-button><a-button v-if="auth.can('sop.edit')&&!editable&&version?.status !== 'reviewing'" 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="rail-title"><span>流程节点</span><a-button v-if="canEdit" 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>
|
||||
<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="canEdit&&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>
|
||||
<div class="node-editor-head"><div><span class="node-type-mark">{{ selected.type.toUpperCase() }}</span><h2>{{ selected.title }}</h2></div><a-button v-if="canEdit&&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-select v-model:value="selected.config.field_key" :disabled="!editable" show-search option-filter-prop="label" placeholder="选择需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></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==='form'" label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!editable" show-search option-filter-prop="label" placeholder="选择本步骤需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item>
|
||||
<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-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!editable" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" /></a-form-item>
|
||||
<div class="property-grid"><a-form-item label="节点名称"><a-input v-model:value="selected.title" :disabled="!canEdit" /></a-form-item><a-form-item label="节点类型"><a-select v-model:value="selected.type" :disabled="!canEdit||selected.type==='start'" :options="nodeTypes" /></a-form-item></div>
|
||||
<a-form-item label="标准话术 / 操作提示"><a-textarea v-model:value="selected.content" :disabled="!canEdit" :rows="5" placeholder="执行到此节点时展示给一线人员的内容" /></a-form-item>
|
||||
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入场景字段"><a-select v-model:value="selected.config.field_key" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!canEdit" /></a-form-item></div></template>
|
||||
<a-form-item v-if="selected.type==='form'" label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择本步骤需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item>
|
||||
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!canEdit" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
||||
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!canEdit" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" /></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>
|
||||
<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="canEdit" 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="canEdit&&!['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>
|
||||
|
||||
@@ -4,10 +4,12 @@ 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 { useAuthStore } from '@/stores/auth'
|
||||
import type { Scenario, ScenarioField, SOP } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const scenarioId = Number(route.params.id)
|
||||
const loading = ref(true)
|
||||
const scenario = ref<Scenario | null>(null)
|
||||
@@ -59,16 +61,16 @@ onMounted(load)
|
||||
<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>
|
||||
<div class="toolbar"><div><b>采集字段</b><span class="toolbar-note">执行过程中收集的信息</span></div><a-button v-if="auth.can('scenario.edit')" 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 #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 v-if="auth.can('scenario.edit')" 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 流程">
|
||||
<a-tab-pane v-if="auth.can('sop.view')" 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 class="toolbar"><div><b>话术流程</b><span class="toolbar-note">一个场景可以包含多套 SOP</span></div><a-button v-if="auth.can('sop.edit')" 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>
|
||||
@@ -78,7 +80,7 @@ onMounted(load)
|
||||
</a-tabs>
|
||||
</a-skeleton>
|
||||
|
||||
<a-modal v-model:open="fieldOpen" title="添加场景字段" ok-text="添加字段" @ok="createField">
|
||||
<a-modal v-if="auth.can('scenario.edit')" 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>
|
||||
@@ -86,7 +88,7 @@ onMounted(load)
|
||||
<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-modal v-if="auth.can('sop.edit')" 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>
|
||||
|
||||
@@ -4,9 +4,11 @@ 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 { useAuthStore } from '@/stores/auth'
|
||||
import type { Scenario } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const open = ref(false)
|
||||
const keyword = ref('')
|
||||
@@ -38,7 +40,7 @@ onMounted(load)
|
||||
<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>
|
||||
<a-button v-if="auth.can('scenario.edit')" type="primary" @click="open = true"><PlusOutlined />新建场景</a-button>
|
||||
</div>
|
||||
<section class="surface">
|
||||
<div class="toolbar">
|
||||
@@ -50,13 +52,13 @@ onMounted(load)
|
||||
<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 v-else-if="column.key === 'action'"><a-button type="link" size="small">{{ auth.can('scenario.edit')?'配置':'查看' }}</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-drawer v-if="auth.can('scenario.edit')" 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>
|
||||
|
||||
Reference in New Issue
Block a user