feat(aihr): complete uni-app role workflows
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>银城员工端</title>
|
||||
<script>
|
||||
(function () {
|
||||
var path = window.location.pathname.replace(/\/+$/, '');
|
||||
var routes = {
|
||||
'/h5/user': '/pages/user/today/index',
|
||||
'/h5/employee': '/pages/user/today/index',
|
||||
'/h5/user/practice': '/pages/user/practice/index',
|
||||
'/h5/user/training': '/pages/user/practice/index',
|
||||
'/h5/user/sop': '/pages/user/sop/index',
|
||||
'/h5/user/cases': '/pages/user/cases/index',
|
||||
'/h5/user/profile': '/pages/user/profile/index',
|
||||
'/h5/candidate': '/pages/candidate/index/index',
|
||||
'/h5/supervisor': '/pages/supervisor/index/index',
|
||||
'/h5/admin': '/pages/supervisor/index/index'
|
||||
};
|
||||
var route = routes[path];
|
||||
if (route && (!window.location.hash || window.location.hash === '#/')) {
|
||||
window.location.replace('/h5/#' + route + window.location.search);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7310
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package",
|
||||
"name": "aihr-mobile-uni",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "uni -p h5",
|
||||
"dev:h5": "uni -p h5",
|
||||
"build:h5": "uni build -p h5",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-5020120260706001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-5020120260706001",
|
||||
"vue": "3.5.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "3.4.31",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5020120260706001",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "5.2.8",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0",
|
||||
"npm": ">=8.19.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch } from '@dcloudio/uni-app';
|
||||
|
||||
onLaunch(() => {
|
||||
// App 只保留全局生命周期。业务页面放在 pages 下。
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import './styles/base.css';
|
||||
</style>
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createSSRApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
return { app };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "银城员工端",
|
||||
"appid": "__UNI__AIHR",
|
||||
"description": "物业AI人力资源系统用户侧",
|
||||
"versionName": "0.1.0",
|
||||
"versionCode": "100",
|
||||
"h5": {
|
||||
"router": {
|
||||
"base": "/h5/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/user/today/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "今日"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/practice/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "练"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/sop/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "问师傅"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/cases/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "案例素材"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/login/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/common/building/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "建设中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/supervisor/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "主管"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/candidate/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "候选人"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "银城员工端",
|
||||
"navigationBarBackgroundColor": "#f8fafc",
|
||||
"backgroundColor": "#f5f7fb"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#64748b",
|
||||
"selectedColor": "#0f172a",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/user/today/index",
|
||||
"text": "今日"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/user/practice/index",
|
||||
"text": "练"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/user/sop/index",
|
||||
"text": "问"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/user/profile/index",
|
||||
"text": "我"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<view class="page login-page">
|
||||
<view class="login-head">
|
||||
<text class="page-title">银城员工端</text>
|
||||
<text class="page-subtitle">使用手机号登录后,回到当前任务。</text>
|
||||
</view>
|
||||
|
||||
<view class="panel login-card">
|
||||
<text class="panel-title">手机号登录</text>
|
||||
<input
|
||||
v-model="phone"
|
||||
class="input"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
<view class="inline-actions">
|
||||
<input
|
||||
v-model="smsCode"
|
||||
class="input code-input"
|
||||
type="number"
|
||||
maxlength="6"
|
||||
placeholder="验证码"
|
||||
/>
|
||||
<button class="secondary-button code-button" :disabled="sending" @click="sendCode">
|
||||
{{ sending ? '发送中' : '发验证码' }}
|
||||
</button>
|
||||
</view>
|
||||
<button class="primary-button" :disabled="loggingIn" @click="login">
|
||||
{{ loggingIn ? '登录中' : '登录' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { LoginResult } from '@/types/api';
|
||||
import { apiRequest } from '@/services/api';
|
||||
import { consumeLoginRedirect, getAuth, isLoggedIn, phoneKey, saveAuth, validatePhone } from '@/services/auth';
|
||||
|
||||
const stored = getAuth();
|
||||
const phone = ref(stored.phone || uni.getStorageSync(phoneKey) || '');
|
||||
const smsCode = ref('');
|
||||
const sending = ref(false);
|
||||
const loggingIn = ref(false);
|
||||
const message = ref('');
|
||||
const tabPages = new Set([
|
||||
'/pages/user/today/index',
|
||||
'/pages/user/practice/index',
|
||||
'/pages/user/sop/index',
|
||||
'/pages/user/profile/index'
|
||||
]);
|
||||
|
||||
const goAfterLogin = () => {
|
||||
const redirect = consumeLoginRedirect() || '/pages/user/today/index';
|
||||
if (tabPages.has(redirect)) {
|
||||
uni.switchTab({ url: redirect });
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: redirect });
|
||||
};
|
||||
|
||||
const sendCode = async () => {
|
||||
if (sending.value) return;
|
||||
const value = phone.value.trim();
|
||||
if (!validatePhone(value)) {
|
||||
message.value = '请输入正确手机号';
|
||||
return;
|
||||
}
|
||||
sending.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
await apiRequest<void>({
|
||||
url: `/resource/sms/code?phonenumber=${encodeURIComponent(value)}`,
|
||||
method: 'GET',
|
||||
auth: false
|
||||
});
|
||||
message.value = '验证码已发送';
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '验证码发送失败';
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const login = async () => {
|
||||
if (loggingIn.value) return;
|
||||
const value = phone.value.trim();
|
||||
if (!validatePhone(value)) {
|
||||
message.value = '请输入正确手机号';
|
||||
return;
|
||||
}
|
||||
if (!smsCode.value.trim()) {
|
||||
message.value = '请输入短信验证码';
|
||||
return;
|
||||
}
|
||||
loggingIn.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await apiRequest<LoginResult>({
|
||||
url: '/auth/mobile/sms-login',
|
||||
method: 'POST',
|
||||
data: {
|
||||
phonenumber: value,
|
||||
smsCode: smsCode.value.trim(),
|
||||
tenantId: '000000'
|
||||
},
|
||||
auth: false
|
||||
});
|
||||
if (!result.access_token || !result.client_id) {
|
||||
throw new Error('登录响应缺少认证信息');
|
||||
}
|
||||
saveAuth(result, value);
|
||||
goAfterLogin();
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '登录失败';
|
||||
} finally {
|
||||
loggingIn.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
if (isLoggedIn()) {
|
||||
goAfterLogin();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-button {
|
||||
width: 112px;
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,746 @@
|
||||
<template>
|
||||
<view class="page candidate-page">
|
||||
<view class="page-head">
|
||||
<view>
|
||||
<text class="page-title">候选人</text>
|
||||
<text class="page-subtitle">{{ candidateSubtitle }}</text>
|
||||
</view>
|
||||
<button v-if="loggedIn" class="ghost-button refresh-button" :disabled="loading" @click="loadPage">刷新</button>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
|
||||
<view v-if="!loggedIn" class="panel login-panel">
|
||||
<view>
|
||||
<text class="panel-title">需要登录</text>
|
||||
<text class="muted block">登录后查看面试任务并提交补充资料。</text>
|
||||
</view>
|
||||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<view v-if="candidatePrimary" class="panel task-panel">
|
||||
<text class="task-badge">{{ candidatePrimary.badge }}</text>
|
||||
<text class="task-title">{{ candidatePrimary.title }}</text>
|
||||
<text class="task-desc">{{ candidatePrimary.desc }}</text>
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: `${candidatePrimary.progress}%` }" />
|
||||
</view>
|
||||
<text class="muted">{{ candidatePrimary.progressText }}</text>
|
||||
<button class="primary-button interview-start" :disabled="primaryBusy" @click="runPrimaryAction">
|
||||
{{ interviewStatus === 'active' && !needsMaterialUpload ? '重新生成题目' : primaryBusy ? '处理中' : candidatePrimary.cta }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="panel progress-panel">
|
||||
<text class="panel-title">进度通知</text>
|
||||
<text class="progress-notice">{{ progressNotice }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="interviewQuestions.length" class="panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">面试问答</text>
|
||||
</view>
|
||||
<view class="question-list">
|
||||
<view v-for="item in interviewQuestions" :key="item.questionId" class="question-card">
|
||||
<text class="question-meta">{{ item.seq }} · {{ item.scenario }}</text>
|
||||
<text class="question-title">{{ item.questionText }}</text>
|
||||
<text class="question-point">{{ item.point }}</text>
|
||||
<textarea
|
||||
v-model="interviewAnswers[item.questionId]"
|
||||
class="textarea answer-textarea"
|
||||
maxlength="400"
|
||||
:disabled="interviewStatus !== 'active'"
|
||||
placeholder="输入你的回答"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
|
||||
提交评分
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="interviewResult" class="panel result-panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">面试结果</text>
|
||||
<text class="result-score">{{ interviewResult.totalScore }}分</text>
|
||||
</view>
|
||||
<text class="result-title">{{ interviewResult.result }}</text>
|
||||
<text class="result-desc">{{ interviewResult.aiSummary }}</text>
|
||||
<view v-if="interviewResult.dimensions?.length" class="dimension-list">
|
||||
<view v-for="item in interviewResult.dimensions" :key="item.label" class="dimension-row">
|
||||
<view>
|
||||
<text class="dimension-title">{{ item.label }}</text>
|
||||
<text class="dimension-note">{{ item.note }}</text>
|
||||
</view>
|
||||
<text class="dimension-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">最近面试</text>
|
||||
<text class="muted">{{ interviewRecords.length }} 条</text>
|
||||
</view>
|
||||
<view v-if="interviewRecords.length" class="interview-list">
|
||||
<view v-for="item in interviewRecords" :key="`${item.time}-${item.candidate}-${item.score}`" class="interview-row">
|
||||
<view class="interview-copy">
|
||||
<text class="interview-title">{{ item.candidate }} · {{ item.position }}</text>
|
||||
<text class="interview-desc">{{ item.time }} · {{ item.summary }}</text>
|
||||
</view>
|
||||
<view class="interview-score">
|
||||
<text>{{ item.score }}</text>
|
||||
<text>{{ item.result }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty">暂无面试记录</view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">岗前预习</text>
|
||||
<button class="ghost-button study-button" :disabled="studyLoading" @click="loadStudy">
|
||||
{{ loggedIn ? studyLoading ? '检索中' : '生成预习' : '登录查看' }}
|
||||
</button>
|
||||
</view>
|
||||
<text v-if="studyAnswer" class="study-answer">{{ studyAnswer }}</text>
|
||||
<view v-if="studyRefs.length" class="study-refs">
|
||||
<text v-for="item in studyRefs" :key="item" class="study-ref">{{ item }}</text>
|
||||
</view>
|
||||
<view v-if="!studyAnswer" class="empty">{{ loggedIn ? '点击生成预习,从正式知识库生成要点' : '登录后从正式知识库生成预习要点' }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">补充资料</text>
|
||||
<button class="primary-button upload-button" :disabled="uploading" @click="uploadMaterial">
|
||||
{{ uploading ? '上传中' : '上传' }}
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="materials.length" class="material-list">
|
||||
<view v-for="item in materials" :key="item.id" class="material-row">
|
||||
<view class="material-copy">
|
||||
<text class="material-title">{{ item.fileName }}</text>
|
||||
<text class="material-desc">OSS {{ item.ossId }} · {{ item.materialType }} · {{ formatSize(item.fileSize) }}</text>
|
||||
</view>
|
||||
<view class="status-pill" :class="statusTone(item.status)">
|
||||
{{ item.status }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty">暂无已提交资料</view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<text class="panel-title">流程</text>
|
||||
<view class="step-list">
|
||||
<view v-for="step in steps" :key="step.title" class="step-row">
|
||||
<text class="step-dot">{{ step.index }}</text>
|
||||
<view>
|
||||
<text class="step-title">{{ step.title }}</text>
|
||||
<text class="step-desc">{{ step.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { CandidateMaterial, InterviewFinishResponse, InterviewQuestion, InterviewRecord, PrimaryTask, RoleHome } from '@/types/api';
|
||||
import { getMobileHome } from '@/services/home';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { searchKnowledge } from '@/services/knowledge';
|
||||
import {
|
||||
chooseCandidateFile,
|
||||
finishCandidateInterview,
|
||||
getCandidateInterviewRecords,
|
||||
getCandidateName,
|
||||
getCandidateMaterials,
|
||||
saveInterviewAnswer,
|
||||
startCandidateInterview,
|
||||
uploadCandidateMaterial
|
||||
} from '@/services/candidate';
|
||||
|
||||
const home = ref<RoleHome | null>(null);
|
||||
const materials = ref<CandidateMaterial[]>([]);
|
||||
const interviewRecords = ref<InterviewRecord[]>([]);
|
||||
const interviewQuestions = ref<InterviewQuestion[]>([]);
|
||||
const interviewAnswers = ref<Record<string, string>>({});
|
||||
const interviewResult = ref<InterviewFinishResponse | null>(null);
|
||||
const interviewSessionId = ref('');
|
||||
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
|
||||
const loggedIn = ref(false);
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const studyLoading = ref(false);
|
||||
const studyAnswer = ref('');
|
||||
const studyRefs = ref<string[]>([]);
|
||||
const message = ref('');
|
||||
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
|
||||
const latestMaterialStatus = computed(() => materials.value[0]?.status || '');
|
||||
const candidateSubtitle = computed(() => loggedIn.value ? `尾号${getCandidateName().slice(-4)},面试、补资料、等通知` : '面试、补资料、等通知');
|
||||
const hasInterview = computed(() => interviewRecords.value.length > 0 || interviewStatus.value === 'finished');
|
||||
const needsMaterialUpload = computed(() =>
|
||||
loggedIn.value && hasInterview.value && (!materials.value.length || latestMaterialStatus.value === '已驳回')
|
||||
);
|
||||
const primaryBusy = computed(() => loading.value || interviewBusy.value || (needsMaterialUpload.value && uploading.value));
|
||||
const defaultCandidateTask: PrimaryTask = {
|
||||
badge: '待面试',
|
||||
title: '结构化面试',
|
||||
desc: '按岗位题目完成回答,提交后生成面试记录。',
|
||||
progress: 0,
|
||||
progressText: '0/2',
|
||||
cta: '开始面试'
|
||||
};
|
||||
const candidatePrimary = computed<PrimaryTask | null>(() => {
|
||||
if (!loggedIn.value) return null;
|
||||
const base = home.value?.primary || defaultCandidateTask;
|
||||
const materialStatus = latestMaterialStatus.value;
|
||||
if (!hasInterview.value) return { ...base, badge: '待面试', progress: 0, progressText: '0/2', cta: '开始面试' };
|
||||
if (materialStatus === '已通过') return { ...base, badge: '资料已通过', title: '等待后续通知', desc: '面试和补充资料已完成,留意 HR 通知。', progress: 100, progressText: '2/2', cta: '刷新进度' };
|
||||
if (materialStatus === '已驳回') return { ...base, badge: '资料被驳回', title: '重新提交资料', desc: '请按驳回结果补充或替换资料。', progress: 50, progressText: '1/2', cta: '重新上传' };
|
||||
if (materials.value.length) return { ...base, badge: '审核中', title: '资料审核中', desc: '资料已提交,等待 HR 审核。', progress: 75, progressText: '1/2', cta: '刷新进度' };
|
||||
return { ...base, badge: '待补资料', title: '上传补充资料', desc: '面试已完成,请补充简历、证书或图片资料。', progress: 50, progressText: '1/2', cta: '上传资料' };
|
||||
});
|
||||
const progressNotice = computed(() => {
|
||||
if (!loggedIn.value) return '登录后查看面试和资料进度';
|
||||
if (!hasInterview.value) return '待完成结构化面试';
|
||||
if (!materials.value.length) return '面试已完成,请上传补充资料';
|
||||
if (latestMaterialStatus.value === '已通过') return '资料已通过,等待后续通知';
|
||||
if (latestMaterialStatus.value === '已驳回') return '资料被驳回,请重新上传';
|
||||
return '资料已提交,等待 HR 审核';
|
||||
});
|
||||
const steps = [
|
||||
{ index: 1, title: '完成面试', desc: '按岗位题目回答,生成面试记录' },
|
||||
{ index: 2, title: '补充资料', desc: '上传简历、证书或图片资料' },
|
||||
{ index: 3, title: '等待审核', desc: 'HR 审核后同步显示状态' }
|
||||
];
|
||||
|
||||
const clearCandidatePrivateData = () => {
|
||||
materials.value = [];
|
||||
interviewRecords.value = [];
|
||||
interviewQuestions.value = [];
|
||||
interviewAnswers.value = {};
|
||||
interviewResult.value = null;
|
||||
interviewSessionId.value = '';
|
||||
interviewStatus.value = 'idle';
|
||||
studyAnswer.value = '';
|
||||
studyRefs.value = [];
|
||||
};
|
||||
|
||||
const clearInterviewDraft = () => {
|
||||
interviewQuestions.value = [];
|
||||
interviewAnswers.value = {};
|
||||
interviewResult.value = null;
|
||||
interviewSessionId.value = '';
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
|
||||
refreshAuthState();
|
||||
if (wasLoggedIn && !loggedIn.value) {
|
||||
clearCandidatePrivateData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return true;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : fallback;
|
||||
return false;
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
refreshAuthState();
|
||||
const wasLoggedIn = loggedIn.value;
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
home.value = await getMobileHome('candidate');
|
||||
} catch (error) {
|
||||
home.value = null;
|
||||
}
|
||||
try {
|
||||
if (!loggedIn.value) {
|
||||
clearCandidatePrivateData();
|
||||
return;
|
||||
}
|
||||
const [nextMaterials, nextRecords] = await Promise.all([
|
||||
getCandidateMaterials(),
|
||||
getCandidateInterviewRecords()
|
||||
]);
|
||||
materials.value = nextMaterials;
|
||||
interviewRecords.value = nextRecords;
|
||||
} catch (error) {
|
||||
handleRequestError(error, '候选人数据加载失败', wasLoggedIn);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadMaterial = async () => {
|
||||
if (uploading.value) return;
|
||||
if (!loggedIn.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const file = await chooseCandidateFile();
|
||||
const saved = await uploadCandidateMaterial(file);
|
||||
message.value = `资料已上传:#${saved.id} / OSS ${saved.ossId}`;
|
||||
materials.value = await getCandidateMaterials();
|
||||
} catch (error) {
|
||||
handleRequestError(error, '资料上传失败', true);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStudy = async () => {
|
||||
if (studyLoading.value) return;
|
||||
if (!loggedIn.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
studyLoading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await searchKnowledge('生活顾问岗前预习 投诉处理 报事报修 客户沟通');
|
||||
studyAnswer.value = result.answer;
|
||||
studyRefs.value = (result.docs || []).map((item) => item.title).filter(Boolean).slice(0, 3);
|
||||
} catch (error) {
|
||||
handleRequestError(error, '岗前预习生成失败', true);
|
||||
} finally {
|
||||
studyLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startInterview = async () => {
|
||||
if (interviewBusy.value) return;
|
||||
if (!loggedIn.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
interviewStatus.value = 'starting';
|
||||
clearInterviewDraft();
|
||||
message.value = '';
|
||||
try {
|
||||
const data = await startCandidateInterview();
|
||||
interviewSessionId.value = data.sessionId;
|
||||
interviewQuestions.value = data.questions || [];
|
||||
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, '']));
|
||||
interviewStatus.value = 'active';
|
||||
message.value = `已生成 ${interviewQuestions.value.length} 道题`;
|
||||
} catch (error) {
|
||||
interviewStatus.value = 'idle';
|
||||
handleRequestError(error, '面试启动失败', true);
|
||||
}
|
||||
};
|
||||
|
||||
const runPrimaryAction = () => {
|
||||
if (needsMaterialUpload.value) {
|
||||
void uploadMaterial();
|
||||
return;
|
||||
}
|
||||
if (hasInterview.value && materials.value.length) {
|
||||
void loadPage();
|
||||
return;
|
||||
}
|
||||
void startInterview();
|
||||
};
|
||||
|
||||
const finishInterview = async () => {
|
||||
if (interviewBusy.value) return;
|
||||
if (interviewStatus.value !== 'active' || !interviewSessionId.value) {
|
||||
message.value = '请先开始面试';
|
||||
return;
|
||||
}
|
||||
const answers = Object.fromEntries(
|
||||
Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])
|
||||
);
|
||||
if (interviewQuestions.value.some((item) => !answers[item.questionId])) {
|
||||
message.value = `请填写全部 ${interviewQuestions.value.length} 题回答`;
|
||||
return;
|
||||
}
|
||||
interviewStatus.value = 'submitting';
|
||||
message.value = '';
|
||||
try {
|
||||
await Promise.all(Object.entries(answers).filter(([, value]) => value).map(([questionId, answer]) =>
|
||||
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
|
||||
));
|
||||
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
|
||||
interviewRecords.value = interviewResult.value.records?.length
|
||||
? interviewResult.value.records
|
||||
: await getCandidateInterviewRecords();
|
||||
interviewStatus.value = 'finished';
|
||||
message.value = '面试完成,已生成面试记录';
|
||||
} catch (error) {
|
||||
if (!handleRequestError(error, '面试提交失败', true)) {
|
||||
interviewStatus.value = 'active';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (size: number) => {
|
||||
if (!size) return '0KB';
|
||||
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)}KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
const statusTone = (status: string) => {
|
||||
if (status === '已通过') return 'passed';
|
||||
if (status === '已驳回') return 'rejected';
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/candidate/index/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
onShow(loadPage);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.candidate-page {
|
||||
min-height: 100vh;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.page-head,
|
||||
.section-head,
|
||||
.login-panel,
|
||||
.interview-row,
|
||||
.material-row,
|
||||
.step-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.refresh-button,
|
||||
.login-button,
|
||||
.upload-button,
|
||||
.study-button {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-panel {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.task-badge {
|
||||
display: inline-flex;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
background: #fed7aa;
|
||||
color: #9a3412;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
color: #0f172a;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.task-desc {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
margin: 16px 0 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #ffedd5;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #ea580c;
|
||||
}
|
||||
|
||||
.progress-panel {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.progress-notice {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: #1e3a8a;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.interview-start,
|
||||
.finish-button {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.question-list,
|
||||
.dimension-list {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.question-card {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.question-meta {
|
||||
color: #ea580c;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.question-title {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.question-point {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.answer-textarea {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.result-score {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.result-desc {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dimension-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dimension-title {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dimension-note {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dimension-value {
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.interview-list {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.interview-row {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.interview-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.interview-title {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.interview-desc {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.interview-score {
|
||||
display: flex;
|
||||
min-width: 64px;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.interview-score text:first-child {
|
||||
color: #ea580c;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.study-answer {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.study-refs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.study-ref {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.material-list,
|
||||
.step-list {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.material-row {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.material-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.material-title,
|
||||
.step-title {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.material-desc,
|
||||
.step-desc {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-pill.pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-pill.passed {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-pill.rejected {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
justify-content: flex-start;
|
||||
margin-top: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<view class="page building-page">
|
||||
<text class="page-title">{{ title }}</text>
|
||||
<text class="page-subtitle">这个入口正在迁移到新版用户侧,当前先保留明确反馈。</text>
|
||||
<view class="panel">
|
||||
<text class="panel-title">下一步</text>
|
||||
<text class="muted block">优先完成可真实闭环的页面,再补这个入口。</text>
|
||||
<button class="primary-button back-button" @click="goBack">返回</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onLoad } from '@dcloudio/uni-app';
|
||||
|
||||
const title = ref('建设中');
|
||||
|
||||
onLoad((query) => {
|
||||
title.value = query?.title ? `${decodeURIComponent(String(query.title))} · 建设中` : '建设中';
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.building-page {
|
||||
min-height: 100vh;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
margin-top: 18px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<view class="page cases-page">
|
||||
<text class="page-title">案例素材</text>
|
||||
<text class="page-subtitle">把一线语音整理成可复用培训案例。上传后依次完成 ASR、AI 整理和入库。</text>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
|
||||
<view v-if="!loggedIn" class="panel login-panel">
|
||||
<view>
|
||||
<text class="panel-title">需要登录</text>
|
||||
<text class="muted block">登录后才能提交案例素材。</text>
|
||||
</view>
|
||||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="panel">
|
||||
<text class="panel-title">提交素材</text>
|
||||
<view class="file-box">
|
||||
<text class="file-title">{{ selectedFile?.name || '未选择音频' }}</text>
|
||||
<text class="file-desc">{{ selectedFile ? formatSize(selectedFile.size) : '支持 mp3 / wav / m4a / webm 等音频' }}</text>
|
||||
</view>
|
||||
<view class="inline-actions case-actions">
|
||||
<button class="secondary-button" :disabled="busy" @click="selectAudio">选择音频</button>
|
||||
<button class="primary-button" :disabled="busy || !selectedFile" @click="submitCase">
|
||||
{{ busy ? statusText : '上传并入库' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="uploadResult" class="panel">
|
||||
<text class="panel-title">ASR 转写</text>
|
||||
<view class="tag-row">
|
||||
<text v-for="tag in uploadResult.tags" :key="tag.text" class="case-tag">{{ tag.text }}</text>
|
||||
</view>
|
||||
<text class="case-content">{{ uploadResult.transcript }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="organizeResult" class="panel">
|
||||
<text class="panel-title">结构化案例稿</text>
|
||||
<view v-for="item in organizeResult.summary" :key="item.label" class="summary-row">
|
||||
<text class="summary-label">{{ item.label }}</text>
|
||||
<text class="summary-text">{{ item.text }}</text>
|
||||
</view>
|
||||
<text class="case-note">{{ organizeResult.aiSummary }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="curateResult?.records?.length" class="panel">
|
||||
<text class="panel-title">入库结果</text>
|
||||
<view v-for="record in curateResult.records" :key="record.title" class="record-row">
|
||||
<view>
|
||||
<text class="record-title">{{ record.title }}</text>
|
||||
<text class="record-desc">{{ record.time }} · {{ record.owner }} · {{ record.type }}</text>
|
||||
<text class="record-summary">{{ record.summary }}</text>
|
||||
</view>
|
||||
<text class="record-status">{{ record.status }}</text>
|
||||
</view>
|
||||
<text v-if="curateResult.sampleHint" class="case-note">{{ curateResult.sampleHint }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel">
|
||||
<view class="section-head">
|
||||
<text class="panel-title">最近案例</text>
|
||||
<button class="ghost-button refresh-button" :disabled="recordsLoading" @click="refreshRecords">
|
||||
刷新
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="records.length" class="record-list">
|
||||
<view v-for="record in records" :key="`${record.title}-${record.time}`" class="record-row">
|
||||
<view>
|
||||
<text class="record-title">{{ record.title }}</text>
|
||||
<text class="record-desc">{{ record.time }} · {{ record.owner }} · {{ record.type }}</text>
|
||||
<text class="record-summary">{{ record.summary }}</text>
|
||||
</view>
|
||||
<text class="record-status">{{ record.status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty">暂无入库案例</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { CaseCurateResponse, CaseOrganizeResponse, CaseRecord, CaseSelectedFile, CaseUploadResponse } from '@/types/api';
|
||||
import { chooseCaseAudio, curateCase, getCaseRecords, organizeCase, uploadCaseAudio } from '@/services/case';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
|
||||
const loggedIn = ref(false);
|
||||
const selectedFile = ref<CaseSelectedFile | null>(null);
|
||||
const uploadResult = ref<CaseUploadResponse | null>(null);
|
||||
const organizeResult = ref<CaseOrganizeResponse | null>(null);
|
||||
const curateResult = ref<CaseCurateResponse | null>(null);
|
||||
const records = ref<CaseRecord[]>([]);
|
||||
const status = ref<'idle' | 'uploading' | 'organizing' | 'curating'>('idle');
|
||||
const recordsLoading = ref(false);
|
||||
const message = ref('');
|
||||
const busy = computed(() => status.value !== 'idle');
|
||||
const statusText = computed(() => {
|
||||
if (status.value === 'uploading') return '转写中';
|
||||
if (status.value === 'organizing') return '整理中';
|
||||
if (status.value === 'curating') return '入库中';
|
||||
return '处理中';
|
||||
});
|
||||
|
||||
const resetResult = () => {
|
||||
uploadResult.value = null;
|
||||
organizeResult.value = null;
|
||||
curateResult.value = null;
|
||||
};
|
||||
|
||||
const clearCaseData = () => {
|
||||
selectedFile.value = null;
|
||||
records.value = [];
|
||||
resetResult();
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
|
||||
refreshAuthState();
|
||||
if (wasLoggedIn && !loggedIn.value) {
|
||||
clearCaseData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return true;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : fallback;
|
||||
return false;
|
||||
};
|
||||
|
||||
const selectAudio = async () => {
|
||||
if (busy.value || !loggedIn.value) return;
|
||||
message.value = '';
|
||||
try {
|
||||
selectedFile.value = await chooseCaseAudio();
|
||||
resetResult();
|
||||
} catch (error) {
|
||||
message.value = error instanceof Error ? error.message : '选择音频失败';
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecords = async () => {
|
||||
if (recordsLoading.value) return;
|
||||
if (!loggedIn.value) {
|
||||
records.value = [];
|
||||
return;
|
||||
}
|
||||
recordsLoading.value = true;
|
||||
try {
|
||||
records.value = await getCaseRecords();
|
||||
} catch (error) {
|
||||
handleRequestError(error, '最近案例加载失败', true);
|
||||
} finally {
|
||||
recordsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshRecords = () => {
|
||||
if (!loggedIn.value) {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
void loadRecords();
|
||||
};
|
||||
|
||||
const submitCase = async () => {
|
||||
if (!selectedFile.value || busy.value) return;
|
||||
message.value = '';
|
||||
resetResult();
|
||||
try {
|
||||
status.value = 'uploading';
|
||||
uploadResult.value = await uploadCaseAudio(selectedFile.value);
|
||||
status.value = 'organizing';
|
||||
organizeResult.value = await organizeCase(uploadResult.value.caseId);
|
||||
status.value = 'curating';
|
||||
curateResult.value = await curateCase(uploadResult.value.caseId);
|
||||
await loadRecords();
|
||||
selectedFile.value = null;
|
||||
uni.showToast({ title: '案例已入库', icon: 'none' });
|
||||
} catch (error) {
|
||||
handleRequestError(error, '案例处理失败', true);
|
||||
} finally {
|
||||
status.value = 'idle';
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/cases/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const formatSize = (size: number) => {
|
||||
if (!size) return '未知大小';
|
||||
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
refreshAuthState();
|
||||
if (loggedIn.value) {
|
||||
void loadRecords();
|
||||
} else {
|
||||
clearCaseData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cases-page {
|
||||
padding-bottom: 92px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.file-box {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.file-title,
|
||||
.file-desc,
|
||||
.case-content,
|
||||
.case-note,
|
||||
.record-title,
|
||||
.record-desc,
|
||||
.record-summary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-title,
|
||||
.record-title {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.file-desc,
|
||||
.record-desc {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.case-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.case-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.case-tag {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.case-content,
|
||||
.case-note,
|
||||
.record-summary {
|
||||
margin-top: 12px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-row,
|
||||
.record-row {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
display: block;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.record-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-status {
|
||||
flex: 0 0 auto;
|
||||
color: #16a34a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,832 @@
|
||||
<template>
|
||||
<view class="page practice-page">
|
||||
<view class="practice-head">
|
||||
<text class="page-title">场景练</text>
|
||||
<text class="page-subtitle">按当前场景完成三角色对练,结束后自动评分并写入训练记录。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
|
||||
<view v-if="!loggedIn" class="panel login-panel">
|
||||
<view>
|
||||
<text class="panel-title">需要登录</text>
|
||||
<text class="muted block">登录后查看今日任务、开始练习并保存训练记录。</text>
|
||||
</view>
|
||||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel assignment-panel">
|
||||
<view class="assignment-head">
|
||||
<text class="panel-title">今日任务</text>
|
||||
<button class="ghost-button refresh-button" :disabled="assignmentLoading" @click="loadAssignments">刷新</button>
|
||||
</view>
|
||||
<view v-if="assignments.length" class="assignment-list">
|
||||
<view v-for="item in assignments" :key="item.id" class="assignment-row">
|
||||
<view class="assignment-copy">
|
||||
<text class="assignment-badge">{{ item.source === 'daily' ? '每日三题' : '主管专项' }}</text>
|
||||
<text class="assignment-title">{{ item.question || item.scenarioName }}</text>
|
||||
<text v-if="assignmentDesc(item)" class="assignment-desc">{{ assignmentDesc(item) }}</text>
|
||||
<text v-if="item.status === '已完成' && item.referenceAnswer" class="assignment-reference">参考:{{ item.referenceAnswer }}</text>
|
||||
</view>
|
||||
<view v-if="item.source === 'daily'" class="daily-answer">
|
||||
<textarea
|
||||
v-model="drillAnswers[item.id]"
|
||||
class="textarea daily-textarea"
|
||||
maxlength="220"
|
||||
:disabled="item.status === '已完成'"
|
||||
placeholder="写一句你的处理话术"
|
||||
/>
|
||||
<button class="primary-button small-button" :disabled="Boolean(answeringId) || item.status === '已完成'" @click="submitDaily(item)">
|
||||
{{ item.status === '已完成' ? '已完成' : '提交' }}
|
||||
</button>
|
||||
</view>
|
||||
<button v-else class="secondary-button small-button" :disabled="!canStartPractice" @click="startAssignment(item)">开始专项</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty">暂无今日任务</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel scenario-card">
|
||||
<text class="scenario-badge">今日小训</text>
|
||||
<text class="scenario-title">{{ activeScenarioName }}</text>
|
||||
<text class="scenario-desc">业主投诉服务或设施问题时,练习承接情绪、说明流程、给出反馈节点。</text>
|
||||
<view v-if="prepCard" class="prep-card">
|
||||
<view class="prep-head">
|
||||
<text>训练前预习卡</text>
|
||||
</view>
|
||||
<view class="prep-block">
|
||||
<text class="prep-label">要点</text>
|
||||
<text v-for="item in prepCard.keyPoints" :key="`point-${item}`" class="prep-line">{{ item }}</text>
|
||||
</view>
|
||||
<view class="prep-block">
|
||||
<text class="prep-label">红线</text>
|
||||
<text v-for="item in prepCard.redLines" :key="`red-${item}`" class="prep-line">{{ item }}</text>
|
||||
</view>
|
||||
<view class="prep-block">
|
||||
<text class="prep-label">话术</text>
|
||||
<text v-for="item in prepCard.scripts" :key="`script-${item}`" class="prep-line">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-else-if="prepLoading" class="muted">预习卡加载中</text>
|
||||
<button
|
||||
v-if="status === 'idle' || status === 'finished'"
|
||||
class="primary-button"
|
||||
:disabled="busy"
|
||||
@click="start()"
|
||||
>
|
||||
{{ status === 'finished' ? '再练一次' : '开始练习' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="turns.length" class="panel dialogue-card">
|
||||
<view class="dialogue-head">
|
||||
<text class="panel-title">对话</text>
|
||||
<text class="round-label">第 {{ Math.min(roundIndex + 1, 3) }} 轮</text>
|
||||
</view>
|
||||
<view v-if="hasCoachState" class="coach-state">
|
||||
<view class="state-row">
|
||||
<text>情绪 {{ emotionScore }}</text>
|
||||
<view class="state-bar">
|
||||
<view class="state-fill emotion-fill" :style="{ width: `${emotionScore}%` }" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="state-row">
|
||||
<text>信任 {{ trustScore }}</text>
|
||||
<view class="state-bar">
|
||||
<view class="state-fill trust-fill" :style="{ width: `${trustScore}%` }" />
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="redFlagActive" class="red-flag">红线纠偏:先承接诉求,避免推责、绝对承诺或情绪对抗。</text>
|
||||
</view>
|
||||
<view class="turn-list">
|
||||
<view v-for="(turn, index) in turns" :key="index" class="turn-row" :class="turn.role">
|
||||
<text class="turn-role">{{ roleLabel(turn.role) }}</text>
|
||||
<text class="turn-text">{{ turn.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="canAdjustCoach" class="style-actions">
|
||||
<button
|
||||
v-for="item in styleOptions"
|
||||
:key="item.value"
|
||||
class="chip style-chip"
|
||||
:disabled="busy || Boolean(adjustingStyle)"
|
||||
@click="adjustCoachHint(item.value)"
|
||||
>
|
||||
{{ adjustingStyle === item.value ? '调整中' : item.label }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="status === 'active' || status === 'submitting'" class="panel reply-card">
|
||||
<text class="panel-title">你的回应</text>
|
||||
<textarea
|
||||
v-model="draft"
|
||||
class="textarea"
|
||||
maxlength="500"
|
||||
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
|
||||
/>
|
||||
<view class="reply-actions">
|
||||
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
|
||||
{{ helpLoading ? '求助中' : '求助' }}
|
||||
</button>
|
||||
<button class="secondary-button" :disabled="busy || !canFinishPractice" @click="finish()">结束评分</button>
|
||||
<button class="primary-button" :disabled="busy" @click="submit">
|
||||
{{ status === 'submitting' ? '提交中' : '提交回应' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="helpAnswer || helpRefs.length" class="panel help-card">
|
||||
<text class="panel-title">SOP 提示</text>
|
||||
<text v-if="helpAnswer" class="help-answer">{{ helpAnswer }}</text>
|
||||
<view v-if="helpRefs.length" class="help-refs">
|
||||
<text v-for="item in helpRefs" :key="item" class="help-ref">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel result-card">
|
||||
<text class="result-label">本次评分</text>
|
||||
<text class="result-score">{{ result.totalScore }}</text>
|
||||
<text class="result-comment">{{ result.aiComment }}</text>
|
||||
<view v-if="result.mentorRewrite" class="mentor-rewrite">
|
||||
<text>老师傅建议</text>
|
||||
<text>{{ result.mentorRewrite }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
PracticeFinishResponse,
|
||||
PracticePrepCard,
|
||||
PracticeAssignment,
|
||||
PracticeRole,
|
||||
PracticeTurn
|
||||
} from '@/types/api';
|
||||
import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePrepCard, startPractice, submitPracticeTurn } from '@/services/practice';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { searchKnowledge } from '@/services/knowledge';
|
||||
|
||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||
|
||||
const defaultScenarioId = 'complaint-water';
|
||||
const defaultScenarioName = '投诉接待';
|
||||
const status = ref<PracticeStatus>('idle');
|
||||
const sessionId = ref('');
|
||||
const roundIndex = ref(0);
|
||||
const draft = ref('');
|
||||
const message = ref('');
|
||||
const turns = ref<PracticeTurn[]>([]);
|
||||
const result = ref<PracticeFinishResponse | null>(null);
|
||||
const selectedScenarioId = ref(defaultScenarioId);
|
||||
const prepCard = ref<PracticePrepCard | null>(null);
|
||||
const prepLoading = ref(false);
|
||||
const activeScenarioName = ref(defaultScenarioName);
|
||||
const helpLoading = ref(false);
|
||||
const helpAnswer = ref('');
|
||||
const helpRefs = ref<string[]>([]);
|
||||
const lastTraineeText = ref('');
|
||||
const lastCoachRoundIndex = ref<number | null>(null);
|
||||
const adjustingStyle = ref('');
|
||||
const emotionScore = ref(0);
|
||||
const trustScore = ref(0);
|
||||
const redFlagActive = ref(false);
|
||||
const assignments = ref<PracticeAssignment[]>([]);
|
||||
const drillAnswers = ref<Record<number, string>>({});
|
||||
const assignmentLoading = ref(false);
|
||||
const answeringId = ref<number | null>(null);
|
||||
const activeAssignmentId = ref<number | undefined>();
|
||||
const loggedIn = ref(false);
|
||||
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
||||
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
|
||||
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
|
||||
const canFinishPractice = computed(() => hasTraineeReply.value && !redFlagActive.value);
|
||||
const hasCoachState = computed(() => emotionScore.value > 0 || trustScore.value > 0 || redFlagActive.value);
|
||||
const canAdjustCoach = computed(() => status.value === 'active' && lastCoachRoundIndex.value !== null && turns.value.some((item) => item.role === 'coach'));
|
||||
const styleOptions = [
|
||||
{ value: 'serious', label: '再严肃' },
|
||||
{ value: 'gentle', label: '再温柔' },
|
||||
{ value: 'detailed', label: '更细致' },
|
||||
{ value: 'professional', label: '更专业' }
|
||||
];
|
||||
|
||||
const roleLabel = (role: PracticeRole) => {
|
||||
if (role === 'customer') return '业主';
|
||||
if (role === 'coach') return '老师傅';
|
||||
return '我';
|
||||
};
|
||||
|
||||
const assignmentDesc = (item: PracticeAssignment) => {
|
||||
if (item.source === 'daily' && item.status !== '已完成') return '提交后显示反馈与参考答案';
|
||||
return item.feedback || item.reason;
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/practice/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const clearPracticeData = () => {
|
||||
assignments.value = [];
|
||||
drillAnswers.value = {};
|
||||
prepCard.value = null;
|
||||
prepLoading.value = false;
|
||||
turns.value = [];
|
||||
result.value = null;
|
||||
sessionId.value = '';
|
||||
roundIndex.value = 0;
|
||||
draft.value = '';
|
||||
status.value = 'idle';
|
||||
selectedScenarioId.value = defaultScenarioId;
|
||||
activeScenarioName.value = defaultScenarioName;
|
||||
helpLoading.value = false;
|
||||
helpAnswer.value = '';
|
||||
helpRefs.value = [];
|
||||
lastTraineeText.value = '';
|
||||
lastCoachRoundIndex.value = null;
|
||||
adjustingStyle.value = '';
|
||||
emotionScore.value = 0;
|
||||
trustScore.value = 0;
|
||||
redFlagActive.value = false;
|
||||
answeringId.value = null;
|
||||
activeAssignmentId.value = undefined;
|
||||
};
|
||||
|
||||
const requireLogin = () => {
|
||||
refreshAuthState();
|
||||
if (loggedIn.value) return true;
|
||||
message.value = '请先登录后再开始训练';
|
||||
goLogin();
|
||||
return false;
|
||||
};
|
||||
|
||||
const appendTurn = (role: PracticeRole, text?: string) => {
|
||||
if (!text) return;
|
||||
turns.value.push({ role, text });
|
||||
};
|
||||
|
||||
const handleError = (error: unknown, fallback: string) => {
|
||||
const text = error instanceof Error ? error.message : fallback;
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
clearPracticeData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
message.value = text;
|
||||
if (text.includes('认证') || text.includes('登录') || text.includes('401')) {
|
||||
goLogin();
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssignments = async () => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
clearPracticeData();
|
||||
return;
|
||||
}
|
||||
assignmentLoading.value = true;
|
||||
try {
|
||||
assignments.value = await getPracticeAssignments(true);
|
||||
drillAnswers.value = Object.fromEntries(assignments.value.map((item) => [item.id, item.userAnswer || '']));
|
||||
} catch (error) {
|
||||
handleError(error, '今日任务加载失败');
|
||||
} finally {
|
||||
assignmentLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDaily = async (item: PracticeAssignment) => {
|
||||
if (answeringId.value) return;
|
||||
const answer = (drillAnswers.value[item.id] || '').trim();
|
||||
if (!answer) {
|
||||
message.value = '请先填写处理话术';
|
||||
return;
|
||||
}
|
||||
answeringId.value = item.id;
|
||||
message.value = '';
|
||||
try {
|
||||
const result = await answerDailyDrill(item.id, answer);
|
||||
assignments.value = assignments.value.map((row) =>
|
||||
row.id === item.id
|
||||
? { ...row, status: result.status, userAnswer: answer, feedback: result.feedback, referenceAnswer: result.referenceAnswer, correct: result.correct }
|
||||
: row
|
||||
);
|
||||
message.value = `每日三题得分 ${result.score}`;
|
||||
} catch (error) {
|
||||
handleError(error, '每日三题提交失败');
|
||||
} finally {
|
||||
answeringId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startAssignment = (item: PracticeAssignment) => {
|
||||
if (!canStartPractice.value) return;
|
||||
selectedScenarioId.value = item.scenarioId || defaultScenarioId;
|
||||
activeScenarioName.value = item.scenarioName || defaultScenarioName;
|
||||
void loadPrepCard(selectedScenarioId.value);
|
||||
void start(item.scenarioId || defaultScenarioId, item.id);
|
||||
};
|
||||
|
||||
const loadPrepCard = async (scenarioId = selectedScenarioId.value) => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
prepCard.value = null;
|
||||
return;
|
||||
}
|
||||
prepLoading.value = true;
|
||||
try {
|
||||
prepCard.value = await getPracticePrepCard(scenarioId);
|
||||
} catch (error) {
|
||||
prepCard.value = null;
|
||||
handleError(error, '预习卡加载失败');
|
||||
} finally {
|
||||
prepLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||
if (!canStartPractice.value) return;
|
||||
if (!requireLogin()) return;
|
||||
selectedScenarioId.value = scenarioId;
|
||||
status.value = 'starting';
|
||||
message.value = '';
|
||||
result.value = null;
|
||||
turns.value = [];
|
||||
draft.value = '';
|
||||
helpAnswer.value = '';
|
||||
helpRefs.value = [];
|
||||
lastTraineeText.value = '';
|
||||
lastCoachRoundIndex.value = null;
|
||||
adjustingStyle.value = '';
|
||||
emotionScore.value = 0;
|
||||
trustScore.value = 0;
|
||||
redFlagActive.value = false;
|
||||
roundIndex.value = 0;
|
||||
activeAssignmentId.value = assignmentId;
|
||||
try {
|
||||
const data = await startPractice(scenarioId, assignmentId);
|
||||
activeScenarioName.value = data.scenarioName || activeScenarioName.value;
|
||||
emotionScore.value = data.emotion ?? 0;
|
||||
trustScore.value = data.trust ?? 0;
|
||||
sessionId.value = data.sessionId;
|
||||
appendTurn('customer', data.customerText);
|
||||
status.value = 'active';
|
||||
} catch (error) {
|
||||
status.value = 'idle';
|
||||
activeAssignmentId.value = undefined;
|
||||
handleError(error, '训练启动失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadHelp = async () => {
|
||||
if (helpLoading.value || busy.value) return;
|
||||
if (!requireLogin()) return;
|
||||
helpLoading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const data = await searchKnowledge(`${activeScenarioName.value} SOP 处理要点`);
|
||||
if (!data.answer) {
|
||||
throw new Error('未检索到可用 SOP 提示');
|
||||
}
|
||||
helpAnswer.value = data.answer;
|
||||
helpRefs.value = (data.snippets || []).map((item) => item.docTitle).filter(Boolean).slice(0, 3);
|
||||
} catch (error) {
|
||||
handleError(error, '求助失败');
|
||||
} finally {
|
||||
helpLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const replaceLastCoachHint = (text?: string) => {
|
||||
if (!text) return;
|
||||
for (let index = turns.value.length - 1; index >= 0; index -= 1) {
|
||||
if (turns.value[index].role === 'coach') {
|
||||
turns.value[index] = { role: 'coach', text };
|
||||
return;
|
||||
}
|
||||
}
|
||||
appendTurn('coach', text);
|
||||
};
|
||||
|
||||
const discardLastCoachAttempt = () => {
|
||||
const next = [...turns.value];
|
||||
if (next[next.length - 1]?.role === 'coach') next.pop();
|
||||
if (next[next.length - 1]?.role === 'trainee') next.pop();
|
||||
turns.value = next;
|
||||
};
|
||||
|
||||
const adjustCoachHint = async (style: string) => {
|
||||
if (busy.value || adjustingStyle.value) return;
|
||||
if (!sessionId.value || lastCoachRoundIndex.value === null || !lastTraineeText.value) return;
|
||||
adjustingStyle.value = style;
|
||||
message.value = '';
|
||||
try {
|
||||
const data = await submitPracticeTurn({
|
||||
sessionId: sessionId.value,
|
||||
roundIndex: lastCoachRoundIndex.value,
|
||||
traineeText: lastTraineeText.value,
|
||||
regenerate: true,
|
||||
style
|
||||
});
|
||||
replaceLastCoachHint(data.coachHint);
|
||||
emotionScore.value = data.emotion ?? emotionScore.value;
|
||||
trustScore.value = data.trust ?? trustScore.value;
|
||||
redFlagActive.value = Boolean(data.redFlag);
|
||||
if (data.message) message.value = data.message;
|
||||
} catch (error) {
|
||||
handleError(error, '建议调整失败');
|
||||
} finally {
|
||||
adjustingStyle.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (busy.value) return;
|
||||
const text = draft.value.trim();
|
||||
if (!text) {
|
||||
message.value = '请先输入回应';
|
||||
return;
|
||||
}
|
||||
status.value = 'submitting';
|
||||
message.value = '';
|
||||
draft.value = '';
|
||||
const retryingRedFlag = redFlagActive.value && lastCoachRoundIndex.value === roundIndex.value;
|
||||
if (retryingRedFlag) discardLastCoachAttempt();
|
||||
appendTurn('trainee', text);
|
||||
try {
|
||||
const submittedRound = roundIndex.value;
|
||||
const data = await submitPracticeTurn({
|
||||
sessionId: sessionId.value,
|
||||
roundIndex: submittedRound,
|
||||
traineeText: text
|
||||
});
|
||||
lastTraineeText.value = text;
|
||||
lastCoachRoundIndex.value = submittedRound;
|
||||
emotionScore.value = data.emotion ?? emotionScore.value;
|
||||
trustScore.value = data.trust ?? trustScore.value;
|
||||
redFlagActive.value = Boolean(data.redFlag);
|
||||
appendTurn('coach', data.redFlag ? data.coachHint || '先承接诉求,再给处理节点。' : data.coachHint);
|
||||
if (data.redFlag) {
|
||||
message.value = '命中红线,请按老师傅建议重说本轮';
|
||||
status.value = 'active';
|
||||
return;
|
||||
}
|
||||
if (data.finished) {
|
||||
await finish(true);
|
||||
return;
|
||||
}
|
||||
appendTurn('customer', data.customerText);
|
||||
roundIndex.value = data.roundIndex;
|
||||
status.value = 'active';
|
||||
} catch (error) {
|
||||
status.value = 'active';
|
||||
handleError(error, '提交失败');
|
||||
}
|
||||
};
|
||||
|
||||
const finish = async (force = false) => {
|
||||
if (!force && busy.value) return;
|
||||
if (!sessionId.value) return;
|
||||
if (!canFinishPractice.value) {
|
||||
message.value = redFlagActive.value ? '请先按老师傅建议重说本轮' : '请先完成至少一轮回应';
|
||||
return;
|
||||
}
|
||||
status.value = 'submitting';
|
||||
message.value = '';
|
||||
try {
|
||||
result.value = await finishPractice(sessionId.value, activeAssignmentId.value);
|
||||
status.value = 'finished';
|
||||
message.value = '训练完成,主管端待复盘已更新';
|
||||
activeAssignmentId.value = undefined;
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
status.value = 'active';
|
||||
handleError(error, '评分失败');
|
||||
}
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
refreshAuthState();
|
||||
void loadAssignments();
|
||||
void loadPrepCard();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.practice-page {
|
||||
padding-bottom: 92px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.practice-head {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.scenario-card,
|
||||
.reply-card,
|
||||
.help-card,
|
||||
.result-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assignment-head,
|
||||
.daily-answer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.refresh-button,
|
||||
.small-button {
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
.assignment-list {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.assignment-row {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.assignment-badge {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.assignment-title {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.assignment-desc,
|
||||
.assignment-reference {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.daily-answer {
|
||||
margin-top: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.daily-textarea {
|
||||
min-height: 82px;
|
||||
}
|
||||
|
||||
.scenario-badge {
|
||||
width: fit-content;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.scenario-title {
|
||||
color: #0f172a;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
color: #64748b;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.prep-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.prep-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.prep-head text:first-child,
|
||||
.prep-label {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.prep-head text:last-child {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prep-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.prep-line {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dialogue-head,
|
||||
.reply-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.round-label {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.coach-state {
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.state-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 10px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.state-bar {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.state-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.emotion-fill {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.trust-fill {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.red-flag {
|
||||
padding: 9px 10px;
|
||||
border-radius: 12px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.turn-list {
|
||||
display: flex;
|
||||
margin-top: 14px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.turn-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.turn-row.trainee {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.turn-row.coach {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.turn-role {
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.turn-text {
|
||||
color: #172033;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.style-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.style-chip {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.reply-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.help-answer {
|
||||
color: #334155;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.help-refs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-ref {
|
||||
padding: 5px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.result-score {
|
||||
color: #1d4ed8;
|
||||
font-size: 44px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.result-comment,
|
||||
.mentor-rewrite text:last-child {
|
||||
color: #334155;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mentor-rewrite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.mentor-rewrite text:first-child {
|
||||
color: #0f172a;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<view class="page profile-page">
|
||||
<text class="page-title">我</text>
|
||||
<text class="page-subtitle">{{ phone || '未登录' }}</text>
|
||||
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
|
||||
<view class="panel account-card">
|
||||
<view>
|
||||
<text class="panel-title">账号</text>
|
||||
<text class="account-phone">{{ phone || '未登录' }}</text>
|
||||
</view>
|
||||
<button v-if="!loggedIn" class="primary-button account-action" @click="goLogin">去登录</button>
|
||||
<button v-else class="secondary-button account-action" @click="logout">退出</button>
|
||||
</view>
|
||||
|
||||
<view v-if="profile" class="panel">
|
||||
<text class="panel-title">成长摘要</text>
|
||||
<view class="score-row">
|
||||
<view class="score-main">
|
||||
<text class="score-value">{{ profile.score }}</text>
|
||||
<text class="score-label">综合能力</text>
|
||||
</view>
|
||||
<view class="metric-grid">
|
||||
<view class="metric">
|
||||
<text>{{ profile.completed }}</text>
|
||||
<text>已训练</text>
|
||||
</view>
|
||||
<view class="metric">
|
||||
<text>{{ profile.pendingReview }}</text>
|
||||
<text>待复盘</text>
|
||||
</view>
|
||||
<view class="metric">
|
||||
<text>{{ profile.trainingMinutes || 0 }}</text>
|
||||
<text>分钟</text>
|
||||
</view>
|
||||
<view class="metric">
|
||||
<text>{{ profile.aiLevel || '初级' }}</text>
|
||||
<text>AI等级</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="profile.dimensions?.length" class="dimension-list">
|
||||
<view v-for="item in profile.dimensions" :key="item.label" class="dimension-row">
|
||||
<view>
|
||||
<text class="dimension-title">{{ item.label }}</text>
|
||||
<text class="dimension-note">{{ item.note }}</text>
|
||||
</view>
|
||||
<text class="dimension-score">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="profile.growthPath?.length" class="growth-section">
|
||||
<text class="section-label">成长路径</text>
|
||||
<view v-for="stage in profile.growthPath" :key="stage.level" class="growth-row">
|
||||
<view class="growth-copy">
|
||||
<text class="growth-title">{{ stage.level }} · {{ stage.status }}</text>
|
||||
<text class="growth-desc">{{ stage.evidence }}</text>
|
||||
</view>
|
||||
<view class="growth-progress">
|
||||
<view class="growth-bar">
|
||||
<view class="growth-fill" :style="{ width: `${stage.progress}%` }" />
|
||||
</view>
|
||||
<text>{{ stage.progress }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="evidence" class="panel evidence-panel">
|
||||
<text class="panel-title">成长证据包</text>
|
||||
<view class="evidence-grid">
|
||||
<view class="evidence-metric">
|
||||
<text>{{ evidence.currentLevel }}</text>
|
||||
<text>当前等级</text>
|
||||
</view>
|
||||
<view class="evidence-metric">
|
||||
<text>{{ evidence.assessmentScore }}</text>
|
||||
<text>测评分</text>
|
||||
</view>
|
||||
<view class="evidence-metric">
|
||||
<text>{{ evidence.trainingMinutes }}</text>
|
||||
<text>训练分钟</text>
|
||||
</view>
|
||||
<view class="evidence-metric">
|
||||
<text>{{ evidence.contribution }}</text>
|
||||
<text>贡献度</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="evidence.evidenceItems?.length" class="evidence-list">
|
||||
<text v-for="item in evidence.evidenceItems" :key="item" class="evidence-item">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="panel">
|
||||
<view class="history-head">
|
||||
<text class="panel-title">最近练过</text>
|
||||
<button class="ghost-button refresh-button" :disabled="loading" @click="loadData">刷新</button>
|
||||
</view>
|
||||
<view v-if="history.length" class="history-list">
|
||||
<view v-for="item in history" :key="item.id || item.sessionId || item.time" class="history-row">
|
||||
<view>
|
||||
<text class="history-title">{{ item.scene }}</text>
|
||||
<text class="history-desc">{{ item.time }} · {{ item.summary }}</text>
|
||||
</view>
|
||||
<view class="history-score">
|
||||
<text>{{ item.score }}</text>
|
||||
<text>{{ item.status }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty">暂无训练记录</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { CompetencyProfile, PracticeRecord, PromotionEvidence } from '@/types/api';
|
||||
import { clearAuth, getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import { getCompetencyProfile, getPracticeHistory, getPromotionEvidence } from '@/services/practice';
|
||||
|
||||
const loggedIn = ref(false);
|
||||
const phone = ref('');
|
||||
const loading = ref(false);
|
||||
const message = ref('');
|
||||
const profile = ref<CompetencyProfile | null>(null);
|
||||
const history = ref<PracticeRecord[]>([]);
|
||||
const evidence = ref<PromotionEvidence | null>(null);
|
||||
|
||||
const refreshAuth = () => {
|
||||
const auth = getAuth();
|
||||
loggedIn.value = isLoggedIn();
|
||||
phone.value = auth.phone;
|
||||
};
|
||||
|
||||
const clearProfileData = () => {
|
||||
profile.value = null;
|
||||
history.value = [];
|
||||
evidence.value = null;
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
refreshAuth();
|
||||
if (!loggedIn.value) {
|
||||
clearProfileData();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
try {
|
||||
const [nextHistory, nextProfile, nextEvidence] = await Promise.all([
|
||||
getPracticeHistory(),
|
||||
getCompetencyProfile(),
|
||||
getPromotionEvidence()
|
||||
]);
|
||||
history.value = nextHistory || [];
|
||||
profile.value = nextProfile || null;
|
||||
evidence.value = nextEvidence || null;
|
||||
} catch (error) {
|
||||
refreshAuth();
|
||||
if (!loggedIn.value) {
|
||||
clearProfileData();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : '我的数据加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/profile/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
clearAuth();
|
||||
clearProfileData();
|
||||
refreshAuth();
|
||||
uni.showToast({ title: '已退出', icon: 'none' });
|
||||
};
|
||||
|
||||
onShow(loadData);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page {
|
||||
padding-bottom: 92px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.account-card,
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.account-phone {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.account-action {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.score-row {
|
||||
display: grid;
|
||||
margin-top: 16px;
|
||||
grid-template-columns: 104px 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.score-main {
|
||||
display: flex;
|
||||
min-height: 124px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18px;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
color: #1d4ed8;
|
||||
font-size: 42px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
margin-top: 8px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.metric text:first-child {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metric text:last-child {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evidence-panel {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.evidence-grid {
|
||||
display: grid;
|
||||
margin-top: 14px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.evidence-metric {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.evidence-metric text:first-child {
|
||||
color: #9a3412;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.evidence-metric text:last-child {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.evidence-list {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.evidence-item {
|
||||
display: block;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid #fed7aa;
|
||||
color: #7c2d12;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dimension-list,
|
||||
.history-list {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.growth-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: block;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.growth-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 96px;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.growth-row:first-of-type {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.growth-title,
|
||||
.growth-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.growth-title {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.growth-desc {
|
||||
margin-top: 5px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.growth-progress {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.growth-bar {
|
||||
width: 88px;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.growth-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.dimension-row,
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dimension-title,
|
||||
.history-title {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.dimension-note,
|
||||
.history-desc {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dimension-score {
|
||||
color: #1d4ed8;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.history-score {
|
||||
display: flex;
|
||||
min-width: 52px;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.history-score text:first-child {
|
||||
color: #1d4ed8;
|
||||
font-size: 20px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.history-score text:last-child {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<view class="page sop-page">
|
||||
<text class="page-title">问师傅</text>
|
||||
<text class="page-subtitle">直接问工作中的 SOP、话术和流程。答案来自知识库检索,引用在后面。</text>
|
||||
|
||||
<view class="panel query-panel">
|
||||
<textarea
|
||||
v-model="query"
|
||||
class="textarea"
|
||||
maxlength="200"
|
||||
placeholder="例如: 业主投诉噪音,生活顾问应该怎么处理?"
|
||||
/>
|
||||
<view class="chip-row">
|
||||
<button v-for="item in quickQuestions" :key="item" class="chip" :disabled="loading" @click="useQuickQuestion(item)">
|
||||
{{ item }}
|
||||
</button>
|
||||
</view>
|
||||
<button class="primary-button" :disabled="loading" @click="search">
|
||||
{{ loading ? '检索中' : '查询' }}
|
||||
</button>
|
||||
<text v-if="message" class="status-message">{{ message }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!loggedIn" class="panel login-panel">
|
||||
<view>
|
||||
<text class="panel-title">需要登录</text>
|
||||
<text class="muted block">登录后才能查询 SOP、生成总结卡并反馈答案。</text>
|
||||
</view>
|
||||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel answer-panel">
|
||||
<text class="panel-title">直给答案</text>
|
||||
<text class="answer-text">{{ result.answer || '未生成直接答案,请查看下方引用。' }}</text>
|
||||
|
||||
<view v-if="result.keyPoints?.length" class="keypoints">
|
||||
<text v-for="(point, index) in result.keyPoints" :key="point" class="keypoint">
|
||||
{{ index + 1 }}. {{ point }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-if="result.cautions?.length" class="cautions">
|
||||
<text v-for="item in result.cautions" :key="item">{{ item }}</text>
|
||||
</view>
|
||||
|
||||
<view class="feedback-actions">
|
||||
<button
|
||||
class="secondary-button"
|
||||
:class="{ active: feedbackVerdict === 'up' }"
|
||||
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
|
||||
@click="feedback('up')"
|
||||
>
|
||||
有用
|
||||
</button>
|
||||
<button
|
||||
class="secondary-button"
|
||||
:class="{ active: feedbackVerdict === 'down' }"
|
||||
:disabled="feedbackSubmitting || Boolean(feedbackVerdict)"
|
||||
@click="feedback('down')"
|
||||
>
|
||||
不准
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result?.snippets?.length" class="panel">
|
||||
<text class="panel-title">引用依据</text>
|
||||
<view v-for="(snippet, index) in result.snippets" :key="`${snippet.docTitle}-${index}`" class="snippet">
|
||||
<text class="snippet-title">{{ snippet.docTitle }}</text>
|
||||
<text class="snippet-content">{{ snippetExcerpt(snippet.content) }}</text>
|
||||
<text v-if="snippet.fragmentId" class="snippet-id">片段 #{{ snippet.fragmentId }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="panel summary-panel">
|
||||
<text class="panel-title">总结卡</text>
|
||||
<text class="muted">需要时再生成,避免每次查询都调用模型。</text>
|
||||
<button class="ghost-button" :disabled="summaryLoading" @click="buildSummaryCard">
|
||||
{{ summaryLoading ? '生成中' : '生成总结卡' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="summaryCard" class="panel">
|
||||
<text class="panel-title">{{ summaryCard.title }}</text>
|
||||
<view class="summary-block">
|
||||
<text class="summary-heading">步骤</text>
|
||||
<text v-for="(step, index) in summaryCard.steps" :key="`${step.label}-${index}`" class="summary-line">
|
||||
{{ index + 1 }}. {{ step.label }}: {{ step.desc }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="summaryCard.scripts.length" class="summary-block">
|
||||
<text class="summary-heading">可说话术</text>
|
||||
<text v-for="item in summaryCard.scripts" :key="item" class="summary-line">{{ item }}</text>
|
||||
</view>
|
||||
<view v-if="summaryCard.objections.length" class="summary-block">
|
||||
<text class="summary-heading">异议回应</text>
|
||||
<text v-for="item in summaryCard.objections" :key="item.concern" class="summary-line">
|
||||
{{ item.concern }}: {{ item.response }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="summaryCard.reminders.length" class="summary-block">
|
||||
<text class="summary-heading">提醒</text>
|
||||
<text v-for="item in summaryCard.reminders" :key="item" class="summary-line">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeSearchResponse,
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import {
|
||||
generateSummaryCard,
|
||||
searchKnowledge,
|
||||
submitAnswerFeedback
|
||||
} from '@/services/knowledge';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
|
||||
const quickQuestions = ['业主投诉噪音怎么安抚', '停车费催缴怎么说', '生活顾问的一天做什么'];
|
||||
const query = ref('');
|
||||
const result = ref<KnowledgeSearchResponse | null>(null);
|
||||
const summaryCard = ref<SummaryCardResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const summaryLoading = ref(false);
|
||||
const feedbackSubmitting = ref(false);
|
||||
const feedbackVerdict = ref<AnswerFeedbackVerdict | ''>('');
|
||||
const message = ref('');
|
||||
const loggedIn = ref(false);
|
||||
const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
|
||||
const resetResultState = () => {
|
||||
result.value = null;
|
||||
summaryCard.value = null;
|
||||
feedbackVerdict.value = '';
|
||||
feedbackSubmitting.value = false;
|
||||
};
|
||||
|
||||
const refreshAuthState = () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown, fallback: string, wasLoggedIn = loggedIn.value) => {
|
||||
refreshAuthState();
|
||||
if (wasLoggedIn && !loggedIn.value) {
|
||||
resetResultState();
|
||||
message.value = '登录已过期,请重新登录';
|
||||
return true;
|
||||
}
|
||||
message.value = error instanceof Error ? error.message : fallback;
|
||||
return false;
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (loading.value) return;
|
||||
const value = query.value.trim();
|
||||
if (!value) {
|
||||
message.value = '请先输入问题';
|
||||
return;
|
||||
}
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
uni.setStorageSync(pendingSopQueryKey, value);
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
resetResultState();
|
||||
try {
|
||||
result.value = await searchKnowledge(value);
|
||||
if (!result.value.answer && !result.value.snippets?.length) {
|
||||
message.value = '未检索到可引用内容';
|
||||
}
|
||||
} catch (error) {
|
||||
handleRequestError(error, '检索失败', true);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const useQuickQuestion = (value: string) => {
|
||||
query.value = value;
|
||||
void search();
|
||||
};
|
||||
|
||||
const snippetExcerpt = (text = '') => {
|
||||
const value = text.trim();
|
||||
return value.length > 260 ? `${value.slice(0, 260)}...` : value;
|
||||
};
|
||||
|
||||
const feedback = async (verdict: AnswerFeedbackVerdict) => {
|
||||
if (!result.value || feedbackSubmitting.value || feedbackVerdict.value) return;
|
||||
const fragmentIds = Array.from(
|
||||
new Set(
|
||||
(result.value.snippets || [])
|
||||
.map((item) => item.fragmentId)
|
||||
.filter((id): id is number => typeof id === 'number' && id > 0)
|
||||
)
|
||||
);
|
||||
if (!fragmentIds.length) {
|
||||
uni.showToast({ title: '暂无可反馈依据', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
feedbackSubmitting.value = true;
|
||||
try {
|
||||
await submitAnswerFeedback({
|
||||
queryText: result.value.queryText || query.value,
|
||||
fragmentIds,
|
||||
verdict
|
||||
});
|
||||
feedbackVerdict.value = verdict;
|
||||
uni.showToast({ title: verdict === 'down' ? '已进入复核' : '已记录', icon: 'none' });
|
||||
} catch (error) {
|
||||
if (!handleRequestError(error, '反馈失败', true)) {
|
||||
uni.showToast({
|
||||
title: message.value,
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
feedbackSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildSummaryCard = async () => {
|
||||
if (summaryLoading.value) return;
|
||||
const value = (result.value?.queryText || query.value).trim();
|
||||
if (!value) return;
|
||||
summaryLoading.value = true;
|
||||
message.value = '';
|
||||
summaryCard.value = null;
|
||||
try {
|
||||
summaryCard.value = await generateSummaryCard(value);
|
||||
} catch (error) {
|
||||
handleRequestError(error, '总结卡生成失败', true);
|
||||
} finally {
|
||||
summaryLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/sop/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
refreshAuthState();
|
||||
if (!loggedIn.value) {
|
||||
resetResultState();
|
||||
}
|
||||
const pending = uni.getStorageSync(pendingSopQueryKey);
|
||||
if (!pending) {
|
||||
if (loggedIn.value && query.value.trim() && message.value === '登录后继续查询该问题') {
|
||||
void search();
|
||||
}
|
||||
return;
|
||||
}
|
||||
query.value = String(pending);
|
||||
if (loggedIn.value) {
|
||||
uni.removeStorageSync(pendingSopQueryKey);
|
||||
void search();
|
||||
} else {
|
||||
message.value = '登录后继续查询该问题';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sop-page {
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.query-panel,
|
||||
.answer-panel,
|
||||
.summary-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.answer-text {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.keypoints {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.keypoint {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cautions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.feedback-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feedback-actions .active {
|
||||
border-color: #16a34a;
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.snippet {
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.snippet-title {
|
||||
color: #0f172a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.snippet-content {
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.snippet-id {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.summary-block {
|
||||
display: flex;
|
||||
margin-top: 14px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-heading {
|
||||
color: #1d4ed8;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.summary-line {
|
||||
color: #334155;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,614 @@
|
||||
<template>
|
||||
<view class="page today-page">
|
||||
<view v-if="message" class="status-message">{{ message }}</view>
|
||||
|
||||
<view class="mentor-hero">
|
||||
<view class="hero-copy">
|
||||
<text class="hero-role">生活顾问</text>
|
||||
<text class="hero-title">客服管家</text>
|
||||
<text class="hero-sub">有问题,随时问老师傅</text>
|
||||
</view>
|
||||
<image class="mentor-image" src="/static/mentor.png" mode="aspectFit" />
|
||||
<view class="mentor-bubble">
|
||||
<text>{{ mentorBubbleText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="voice-panel">
|
||||
<button class="voice-primary" @click="goSop">
|
||||
<text class="voice-mark">问</text>
|
||||
<text>问老师傅</text>
|
||||
</button>
|
||||
<button class="voice-secondary" @click="goSop">
|
||||
<text class="keyboard-mark">键</text>
|
||||
<text>打字问</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="mentor-chips">
|
||||
<view class="chip-row">
|
||||
<button v-for="item in quickQuestions" :key="item" class="chip" @click="ask(item)">
|
||||
{{ item }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-card login-section" v-if="!loggedIn">
|
||||
<view>
|
||||
<text class="login-title">登录后查看今日安排</text>
|
||||
<text class="login-desc">训练任务、复习提醒和成长数据会按你的手机号展示。</text>
|
||||
</view>
|
||||
<button class="primary-button login-button" @click="goLogin">去登录</button>
|
||||
</view>
|
||||
|
||||
<view class="section-card" v-else-if="home?.primary">
|
||||
<view class="section-head">
|
||||
<view class="section-title">
|
||||
<text class="section-icon">日</text>
|
||||
<text>今日安排</text>
|
||||
</view>
|
||||
<button class="section-link" @click="openTool('训练')">查看全部</button>
|
||||
</view>
|
||||
<view class="schedule-list">
|
||||
<view class="schedule-row">
|
||||
<text class="step-dot blue">1</text>
|
||||
<view class="schedule-copy">
|
||||
<text class="schedule-title">每日小训:{{ home.primary.title.replace('三角色对练', '') }}</text>
|
||||
<text class="schedule-desc">{{ home.primary.desc }}</text>
|
||||
</view>
|
||||
<button class="schedule-action blue-action" @click="openTool(home.primary.cta)">开始</button>
|
||||
</view>
|
||||
<view class="schedule-row" v-if="home.review">
|
||||
<text class="step-dot orange">2</text>
|
||||
<view class="schedule-copy">
|
||||
<text class="schedule-title">今日复习:{{ home.review.title }}</text>
|
||||
<text class="schedule-desc">{{ home.review.desc }}</text>
|
||||
</view>
|
||||
<button class="schedule-action orange-action" @click="ask(home.review.title)">去复习</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loggedIn" class="section-card">
|
||||
<view class="section-head">
|
||||
<view class="section-title">
|
||||
<text class="section-icon">图</text>
|
||||
<text>{{ loggedIn ? '我的地图' : '常用入口' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="mapTiles.length" class="map-grid">
|
||||
<button
|
||||
v-for="tile in mapTiles"
|
||||
:key="tile.title"
|
||||
class="map-tile"
|
||||
:class="tile.tone"
|
||||
@click="openTool(tile.title)"
|
||||
>
|
||||
<image class="map-icon" :src="tile.icon" mode="aspectFit" />
|
||||
<view class="map-copy">
|
||||
<text class="map-title">{{ tile.title }}</text>
|
||||
<text class="map-desc">{{ tile.desc }}</text>
|
||||
</view>
|
||||
<text class="map-arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
<view v-else class="empty">暂无工具入口</view>
|
||||
</view>
|
||||
|
||||
<button class="tip-card" @click="ask('催费话术')">
|
||||
<text class="tip-mark">贴</text>
|
||||
<text>今日小贴士:催费沟通先共情,再说明,再给方案。</text>
|
||||
<text class="tip-arrow">›</text>
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { RoleHome } from '@/types/api';
|
||||
import { getMobileHome } from '@/services/home';
|
||||
import { getAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
|
||||
const home = ref<RoleHome | null>(null);
|
||||
const message = ref('');
|
||||
const loggedIn = ref(false);
|
||||
const quickQuestions = ['催费话术', '投诉应对', '收费标准'];
|
||||
const pendingSopQueryKey = 'aihr_sop_pending_query';
|
||||
|
||||
const iconFor = (title: string) => {
|
||||
if (title.includes('SOP') || title.includes('查')) return '/static/map-icons/ask.png';
|
||||
if (title.includes('练')) return '/static/map-icons/practice.png';
|
||||
if (title.includes('学') || title.includes('案例')) return '/static/map-icons/learn.png';
|
||||
return '/static/map-icons/report.png';
|
||||
};
|
||||
|
||||
const toneFor = (title: string) => {
|
||||
if (title.includes('SOP') || title.includes('查')) return 'ask';
|
||||
if (title.includes('练')) return 'practice';
|
||||
if (title.includes('学') || title.includes('案例')) return 'learn';
|
||||
return 'report';
|
||||
};
|
||||
|
||||
const mapTiles = computed(() => {
|
||||
if (!home.value?.tools?.length) return [];
|
||||
return home.value.tools.map((item) => ({
|
||||
title: item.title,
|
||||
desc: item.desc.replace(/\n/g, ' / '),
|
||||
tone: toneFor(item.title),
|
||||
icon: iconFor(item.title)
|
||||
}));
|
||||
});
|
||||
|
||||
const mentorName = computed(() => {
|
||||
if (!loggedIn.value) return '同事';
|
||||
const phone = getAuth().phone.trim();
|
||||
return phone ? `尾号${phone.slice(-4)}的同事` : '同事';
|
||||
});
|
||||
|
||||
const mentorBubbleText = computed(() =>
|
||||
loggedIn.value ? `${mentorName.value},今天先做两件事` : '同事,登录后看今天任务'
|
||||
);
|
||||
|
||||
const loadHome = async () => {
|
||||
loggedIn.value = isLoggedIn();
|
||||
message.value = '';
|
||||
if (!loggedIn.value) {
|
||||
home.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
home.value = await getMobileHome('user');
|
||||
} catch (error) {
|
||||
home.value = null;
|
||||
message.value = error instanceof Error ? error.message : '首页数据加载失败';
|
||||
}
|
||||
};
|
||||
|
||||
const goSop = () => {
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
};
|
||||
|
||||
const ask = (query: string) => {
|
||||
uni.setStorageSync(pendingSopQueryKey, query);
|
||||
uni.switchTab({ url: '/pages/user/sop/index' });
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
rememberLoginRedirect('/pages/user/today/index');
|
||||
uni.navigateTo({ url: '/pages/auth/login/index' });
|
||||
};
|
||||
|
||||
const openTool = (title: string) => {
|
||||
if (title.includes('问') || title.includes('SOP') || title.includes('sop')) {
|
||||
goSop();
|
||||
return;
|
||||
}
|
||||
if (title.includes('练') || title.includes('训练') || title.includes('开始')) {
|
||||
uni.switchTab({ url: '/pages/user/practice/index' });
|
||||
return;
|
||||
}
|
||||
if (title.includes('案例') || title.includes('素材') || title.includes('提交')) {
|
||||
uni.navigateTo({ url: '/pages/user/cases/index' });
|
||||
return;
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/common/building/index?title=${encodeURIComponent(title)}` });
|
||||
};
|
||||
|
||||
onShow(loadHome);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.today-page {
|
||||
min-height: 100vh;
|
||||
padding: 0 14px 86px;
|
||||
background: #f4f7fb;
|
||||
}
|
||||
|
||||
.mentor-hero {
|
||||
position: relative;
|
||||
height: 224px;
|
||||
margin: 0 -14px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(237, 247, 255, 0.72), rgba(244, 247, 251, 0.98)),
|
||||
linear-gradient(135deg, #d9ecff 0%, #f7fbff 45%, #dfeff2 100%);
|
||||
}
|
||||
|
||||
.mentor-hero::before {
|
||||
position: absolute;
|
||||
right: -36px;
|
||||
bottom: 12px;
|
||||
width: 190px;
|
||||
height: 96px;
|
||||
border-radius: 30px;
|
||||
background: rgba(255, 255, 255, 0.46);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 34px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-role,
|
||||
.hero-title {
|
||||
color: #11235a;
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
margin-top: 8px;
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mentor-image {
|
||||
position: absolute;
|
||||
right: 88px;
|
||||
bottom: 14px;
|
||||
z-index: 1;
|
||||
width: 104px;
|
||||
height: 138px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.mentor-bubble {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 80px;
|
||||
z-index: 3;
|
||||
width: 128px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.mentor-bubble text {
|
||||
color: #111827;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.voice-panel {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
margin: -34px 8px 0;
|
||||
padding: 16px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
border-radius: 22px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.voice-primary,
|
||||
.voice-secondary {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
border-radius: 18px;
|
||||
background: transparent;
|
||||
color: #1d4ed8;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.voice-secondary {
|
||||
color: #4b5563;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.voice-primary text:last-child,
|
||||
.voice-secondary text:last-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voice-mark,
|
||||
.keyboard-mark {
|
||||
display: flex;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #2563eb, #1d9bf0);
|
||||
color: #ffffff;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.keyboard-mark {
|
||||
background: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.mentor-chips {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.mentor-chips .chip-row {
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mentor-chips .chip {
|
||||
min-height: 42px;
|
||||
flex: 1;
|
||||
border: 1px solid rgba(203, 213, 225, 0.7);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
color: #1f2937;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.login-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-title,
|
||||
.login-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
margin-top: 6px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.section-head,
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
gap: 9px;
|
||||
color: #111827;
|
||||
font-size: 21px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.schedule-list {
|
||||
display: flex;
|
||||
margin-top: 16px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.schedule-row {
|
||||
display: flex;
|
||||
min-height: 82px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 15px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.step-dot.blue {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.step-dot.orange {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.schedule-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.schedule-title {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.schedule-desc {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.schedule-action {
|
||||
width: 68px;
|
||||
min-height: 42px;
|
||||
flex: none;
|
||||
border-radius: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.blue-action {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.orange-action {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.map-grid {
|
||||
display: grid;
|
||||
margin-top: 16px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.map-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 108px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 12px;
|
||||
border-radius: 17px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.map-tile.ask {
|
||||
background: linear-gradient(135deg, #1d4ed8, #2682ff);
|
||||
}
|
||||
|
||||
.map-tile.practice {
|
||||
background: linear-gradient(135deg, #16a34a, #22c55e);
|
||||
}
|
||||
|
||||
.map-tile.learn {
|
||||
background: linear-gradient(135deg, #f59e0b, #fb923c);
|
||||
}
|
||||
|
||||
.map-tile.report {
|
||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||
}
|
||||
|
||||
.map-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: none;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.map-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-title {
|
||||
color: #ffffff;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.map-desc {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.map-arrow {
|
||||
color: #ffffff;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tip-card {
|
||||
display: flex;
|
||||
margin-top: 18px;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 14px;
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tip-mark {
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.tip-card text:nth-child(2) {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tip-arrow {
|
||||
color: #94a3b8;
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ApiPayload } from '@/types/api';
|
||||
import { clearAuth, getAuth } from './auth';
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || '/dev-api';
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
export interface RequestOptions {
|
||||
url: string;
|
||||
method?: HttpMethod;
|
||||
data?: unknown;
|
||||
header?: Record<string, string>;
|
||||
timeout?: number;
|
||||
auth?: boolean;
|
||||
}
|
||||
|
||||
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
|
||||
|
||||
export const apiUrl = normalizeUrl;
|
||||
|
||||
export const authHeaders = (json = true, includeAuth = true) => {
|
||||
const auth = getAuth();
|
||||
return {
|
||||
...(json ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(includeAuth && auth.token ? { Authorization: `Bearer ${auth.token}` } : {}),
|
||||
...(includeAuth && auth.clientId ? { clientid: auth.clientId } : {})
|
||||
};
|
||||
};
|
||||
|
||||
export const readPayload = <T>(statusCode: number, data: unknown): T => {
|
||||
const payload = data as ApiPayload<T>;
|
||||
const code = typeof payload?.code === 'number' ? payload.code : undefined;
|
||||
if (statusCode < 200 || statusCode >= 300 || (code !== undefined && code !== 200)) {
|
||||
if (statusCode === 401 || statusCode === 403 || code === 401 || code === 403) {
|
||||
clearAuth();
|
||||
}
|
||||
throw new Error(payload?.msg || `HTTP ${statusCode}`);
|
||||
}
|
||||
|
||||
return payload?.data as T;
|
||||
};
|
||||
|
||||
export const parsePayloadText = (statusCode: number, text: string) => {
|
||||
try {
|
||||
return JSON.parse(text || '{}');
|
||||
} catch (error) {
|
||||
if (statusCode === 401 || statusCode === 403) clearAuth();
|
||||
throw error instanceof SyntaxError
|
||||
? new Error(statusCode >= 400 ? `HTTP ${statusCode}` : '响应解析失败')
|
||||
: error;
|
||||
}
|
||||
};
|
||||
|
||||
export const readTextPayload = <T>(statusCode: number, text: string): T =>
|
||||
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
|
||||
|
||||
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
||||
const method = options.method || 'GET';
|
||||
const hasBody = method !== 'GET' && options.data !== undefined;
|
||||
const response = await new Promise<{ status: number; data: unknown }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, normalizeUrl(options.url));
|
||||
xhr.timeout = options.timeout || 30000;
|
||||
Object.entries({
|
||||
...authHeaders(hasBody, options.auth !== false),
|
||||
...options.header
|
||||
}).forEach(([key, value]) => xhr.setRequestHeader(key, value));
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
resolve({ status: xhr.status, data: parsePayloadText(xhr.status, xhr.responseText || 'null') });
|
||||
} catch {
|
||||
reject(new Error(xhr.status >= 400 ? `HTTP ${xhr.status}` : '响应解析失败'));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('网络请求失败'));
|
||||
xhr.ontimeout = () => reject(new Error('网络请求超时'));
|
||||
xhr.send(hasBody ? JSON.stringify(options.data) : undefined);
|
||||
});
|
||||
|
||||
return readPayload<T>(response.status, response.data);
|
||||
};
|
||||
|
||||
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
||||
if (typeof XMLHttpRequest !== 'undefined') {
|
||||
return requestWithXhr<T>(options);
|
||||
}
|
||||
|
||||
const response = await new Promise<any>((resolve, reject) => {
|
||||
uni.request({
|
||||
url: normalizeUrl(options.url),
|
||||
method: options.method || 'GET',
|
||||
data: options.data as string | ArrayBuffer | Record<string, unknown> | undefined,
|
||||
header: {
|
||||
...authHeaders(options.method !== 'GET', options.auth !== false),
|
||||
...options.header
|
||||
},
|
||||
timeout: options.timeout || 30000,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
|
||||
return readPayload<T>(response.statusCode, response.data);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { LoginResult } from '@/types/api';
|
||||
|
||||
export const tokenKey = 'aihr_mobile_access_token';
|
||||
export const clientIdKey = 'aihr_mobile_client_id';
|
||||
export const phoneKey = 'aihr_mobile_phone';
|
||||
export const loginRedirectKey = 'aihr_mobile_login_redirect';
|
||||
export const phonePattern = /^1[3-9]\d{9}$/;
|
||||
|
||||
export interface AuthState {
|
||||
token: string;
|
||||
clientId: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export const getAuth = (): AuthState => ({
|
||||
token: uni.getStorageSync(tokenKey) || '',
|
||||
clientId: uni.getStorageSync(clientIdKey) || '',
|
||||
phone: uni.getStorageSync(phoneKey) || ''
|
||||
});
|
||||
|
||||
export const isLoggedIn = () => {
|
||||
const auth = getAuth();
|
||||
return Boolean(auth.token && auth.clientId && auth.phone);
|
||||
};
|
||||
|
||||
export const saveAuth = (result: LoginResult, phone: string) => {
|
||||
uni.setStorageSync(tokenKey, result.access_token);
|
||||
uni.setStorageSync(clientIdKey, result.client_id);
|
||||
uni.setStorageSync(phoneKey, phone);
|
||||
};
|
||||
|
||||
export const clearAuth = () => {
|
||||
uni.removeStorageSync(tokenKey);
|
||||
uni.removeStorageSync(clientIdKey);
|
||||
uni.removeStorageSync(phoneKey);
|
||||
uni.removeStorageSync(loginRedirectKey);
|
||||
};
|
||||
|
||||
export const rememberLoginRedirect = (url: string) => {
|
||||
uni.setStorageSync(loginRedirectKey, url);
|
||||
};
|
||||
|
||||
export const consumeLoginRedirect = () => {
|
||||
const url = uni.getStorageSync(loginRedirectKey) || '';
|
||||
uni.removeStorageSync(loginRedirectKey);
|
||||
return url;
|
||||
};
|
||||
|
||||
export const validatePhone = (phone: string) => phonePattern.test(phone);
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
CandidateMaterial,
|
||||
CandidateSelectedFile,
|
||||
InterviewFinishResponse,
|
||||
InterviewRecord,
|
||||
InterviewStartResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { getAuth } from './auth';
|
||||
|
||||
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
|
||||
|
||||
export const getCandidateId = () => {
|
||||
const phone = getAuth().phone.trim();
|
||||
if (!phone) {
|
||||
throw new Error('请先登录后再查看候选人进度');
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
export const getCandidateName = () => {
|
||||
const phone = getAuth().phone.trim();
|
||||
return phone ? `候选人${phone.slice(-4)}` : '候选人';
|
||||
};
|
||||
|
||||
export const getCandidateMaterials = () =>
|
||||
apiRequest<CandidateMaterial[]>({
|
||||
url: `/api/aihr/mobile/candidate/materials?candidateId=${encodeURIComponent(getCandidateId())}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getCandidateInterviewRecords = () =>
|
||||
apiRequest<InterviewRecord[]>({
|
||||
url: `/api/recruit/interview/records?limit=5&candidateId=${encodeURIComponent(getCandidateId())}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const chooseCandidateFile = () =>
|
||||
new Promise<CandidateSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: materialExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<CandidateSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'candidate-material',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: () => reject(new Error('未选择文件'))
|
||||
});
|
||||
});
|
||||
|
||||
export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') =>
|
||||
new Promise<CandidateMaterial>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: apiUrl('/api/aihr/mobile/candidate/materials'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
header: authHeaders(false),
|
||||
formData: {
|
||||
candidateId: getCandidateId(),
|
||||
candidateName: getCandidateName(),
|
||||
materialType
|
||||
},
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(readTextPayload<CandidateMaterial>(response.statusCode, response.data));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('资料上传失败'))
|
||||
});
|
||||
});
|
||||
|
||||
export const startCandidateInterview = () =>
|
||||
apiRequest<InterviewStartResponse>({
|
||||
url: '/api/recruit/interview/start',
|
||||
method: 'POST',
|
||||
data: {
|
||||
candidateId: getCandidateId(),
|
||||
candidateName: getCandidateName(),
|
||||
positionCode: 'customer-service',
|
||||
mode: 'mobile'
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const saveInterviewAnswer = (sessionId: string, questionId: string, answerText: string) =>
|
||||
apiRequest<void>({
|
||||
url: '/api/recruit/interview/answer',
|
||||
method: 'POST',
|
||||
data: { sessionId, questionId, answerText },
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const finishCandidateInterview = (sessionId: string, answers: Record<string, string>) =>
|
||||
apiRequest<InterviewFinishResponse>({
|
||||
url: '/api/recruit/interview/finish',
|
||||
method: 'POST',
|
||||
data: { sessionId, answers },
|
||||
timeout: 45000
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
CaseCurateResponse,
|
||||
CaseOrganizeResponse,
|
||||
CaseRecord,
|
||||
CaseSelectedFile,
|
||||
CaseUploadResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
export const chooseCaseAudio = () =>
|
||||
new Promise<CaseSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: audioExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<CaseSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'case-audio',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: () => reject(new Error('未选择音频'))
|
||||
});
|
||||
});
|
||||
|
||||
export const uploadCaseAudio = (file: CaseSelectedFile) =>
|
||||
new Promise<CaseUploadResponse>((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: apiUrl('/api/knowledge/case/upload'),
|
||||
filePath: file.path,
|
||||
name: 'file',
|
||||
header: authHeaders(false),
|
||||
formData: {},
|
||||
success: (response) => {
|
||||
try {
|
||||
resolve(readTextPayload<CaseUploadResponse>(response.statusCode, response.data));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: () => reject(new Error('案例音频上传失败'))
|
||||
});
|
||||
});
|
||||
|
||||
export const organizeCase = (caseId: string) =>
|
||||
apiRequest<CaseOrganizeResponse>({
|
||||
url: '/api/knowledge/case/organize',
|
||||
method: 'POST',
|
||||
data: { caseId },
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
export const curateCase = (caseId: string) =>
|
||||
apiRequest<CaseCurateResponse>({
|
||||
url: '/api/knowledge/case/curate',
|
||||
method: 'POST',
|
||||
data: {
|
||||
caseId,
|
||||
criteria: '一线可复用培训案例',
|
||||
limit: 1
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const getCaseRecords = () =>
|
||||
apiRequest<CaseRecord[]>({
|
||||
url: '/api/knowledge/case/records?limit=10',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RoleHome, RoleKey } from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export const getMobileHome = (role: RoleKey) =>
|
||||
apiRequest<RoleHome>({
|
||||
url: `/api/aihr/mobile/home/${role}`,
|
||||
method: 'GET',
|
||||
auth: false,
|
||||
timeout: 15000
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
AnswerFeedbackVerdict,
|
||||
KnowledgeSearchResponse,
|
||||
KnowledgeSnippet,
|
||||
SummaryCardResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
type RawKnowledgeSnippet = KnowledgeSnippet & {
|
||||
title?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
|
||||
...snippet,
|
||||
docTitle: snippet.docTitle || snippet.title || '',
|
||||
content: snippet.content || snippet.text || ''
|
||||
});
|
||||
|
||||
export const searchKnowledge = async (queryText: string) => {
|
||||
const response = await apiRequest<KnowledgeSearchResponse>({
|
||||
url: '/api/knowledge/search',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queryText,
|
||||
category: 'sop',
|
||||
limit: 5
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
return {
|
||||
...response,
|
||||
snippets: (response.snippets || []).map((item) => normalizeSnippet(item as RawKnowledgeSnippet))
|
||||
};
|
||||
};
|
||||
|
||||
export const submitAnswerFeedback = (params: {
|
||||
queryText: string;
|
||||
fragmentIds: number[];
|
||||
verdict: AnswerFeedbackVerdict;
|
||||
}) =>
|
||||
apiRequest<{ saved: number }>({
|
||||
url: '/api/knowledge/answer-feedback',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queryText: params.queryText,
|
||||
fragmentIds: params.fragmentIds,
|
||||
verdict: params.verdict,
|
||||
category: 'sop',
|
||||
position: '生活顾问',
|
||||
source: 'mobile_uni_sop'
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const generateSummaryCard = (queryText: string) =>
|
||||
apiRequest<SummaryCardResponse>({
|
||||
url: '/api/knowledge/summary-card',
|
||||
method: 'POST',
|
||||
data: {
|
||||
queryText,
|
||||
category: 'sop'
|
||||
},
|
||||
timeout: 45000
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
PracticeFinishResponse,
|
||||
PracticePrepCard,
|
||||
PracticeRecord,
|
||||
PracticeStartResponse,
|
||||
PracticeTurnResponse,
|
||||
CompetencyProfile,
|
||||
ReviewAdviceRequest,
|
||||
ReviewDetail,
|
||||
PracticeAssignment,
|
||||
PracticeAssignmentRequest,
|
||||
DailyDrillAnswerResponse,
|
||||
PracticeAlert,
|
||||
PromotionEvidence
|
||||
} from '@/types/api';
|
||||
import { apiRequest } from './api';
|
||||
import { getAuth } from './auth';
|
||||
|
||||
const defaultScenarioId = 'complaint-water';
|
||||
|
||||
const currentExtPartyId = () => {
|
||||
const phone = getAuth().phone.trim();
|
||||
if (!phone) {
|
||||
throw new Error('请先登录后再查看训练数据');
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
export const getPracticePrepCard = (scenarioId = defaultScenarioId) =>
|
||||
apiRequest<PracticePrepCard>({
|
||||
url: `/api/train/practice/scenarios/${encodeURIComponent(scenarioId)}/prep-card`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const startPractice = (scenarioId = defaultScenarioId, assignmentId?: number) =>
|
||||
apiRequest<PracticeStartResponse>({
|
||||
url: '/api/train/practice/start',
|
||||
method: 'POST',
|
||||
data: {
|
||||
extPartyId: currentExtPartyId(),
|
||||
scenarioId,
|
||||
mode: 'mobile',
|
||||
assignmentId
|
||||
},
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
export const submitPracticeTurn = (params: {
|
||||
sessionId: string;
|
||||
roundIndex: number;
|
||||
traineeText: string;
|
||||
regenerate?: boolean;
|
||||
style?: string;
|
||||
}) =>
|
||||
apiRequest<PracticeTurnResponse>({
|
||||
url: '/api/train/practice/turn',
|
||||
method: 'POST',
|
||||
data: params,
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
export const finishPractice = (sessionId: string, assignmentId?: number) =>
|
||||
apiRequest<PracticeFinishResponse>({
|
||||
url: '/api/train/practice/finish',
|
||||
method: 'POST',
|
||||
data: { sessionId, assignmentId },
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
export const getPracticeHistory = () => {
|
||||
const extPartyId = encodeURIComponent(currentExtPartyId());
|
||||
return apiRequest<PracticeRecord[]>({
|
||||
url: `/api/aihr/mobile/practice/history?extPartyId=${extPartyId}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
};
|
||||
|
||||
export const getCompetencyProfile = () => {
|
||||
const extPartyId = encodeURIComponent(currentExtPartyId());
|
||||
return apiRequest<CompetencyProfile>({
|
||||
url: `/api/aihr/mobile/profile?extPartyId=${extPartyId}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
};
|
||||
|
||||
export const getPromotionEvidence = () => {
|
||||
const extPartyId = encodeURIComponent(currentExtPartyId());
|
||||
return apiRequest<PromotionEvidence>({
|
||||
url: `/api/aihr/mobile/profile/promotion-evidence?extPartyId=${extPartyId}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
};
|
||||
|
||||
export const getPracticeReviews = () =>
|
||||
apiRequest<PracticeRecord[]>({
|
||||
url: '/api/aihr/mobile/practice/reviews',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeReviewDetail = (id: number) =>
|
||||
apiRequest<ReviewDetail>({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const markPracticeReviewed = (id: number, data: ReviewAdviceRequest) =>
|
||||
apiRequest<void>({
|
||||
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeAssignments = (ownOnly = false) => {
|
||||
const extPartyId = ownOnly ? encodeURIComponent(currentExtPartyId()) : '';
|
||||
return apiRequest<PracticeAssignment[]>({
|
||||
url: `/api/aihr/mobile/practice/assignments${ownOnly ? `?extPartyId=${extPartyId}` : ''}`,
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
};
|
||||
|
||||
export const createPracticeAssignment = (data: PracticeAssignmentRequest) =>
|
||||
apiRequest<PracticeAssignment>({
|
||||
url: '/api/aihr/mobile/practice/assignments',
|
||||
method: 'POST',
|
||||
data: {
|
||||
source: 'manual',
|
||||
scenarioId: 'fee-parking',
|
||||
...data
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const answerDailyDrill = (id: number, answer: string) =>
|
||||
apiRequest<DailyDrillAnswerResponse>({
|
||||
url: `/api/aihr/mobile/practice/assignments/${id}/answer`,
|
||||
method: 'POST',
|
||||
data: { answer },
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const getPracticeAlerts = () =>
|
||||
apiRequest<PracticeAlert[]>({
|
||||
url: '/api/aihr/mobile/practice/alerts',
|
||||
method: 'GET',
|
||||
timeout: 15000
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,156 @@
|
||||
page {
|
||||
min-height: 100%;
|
||||
background: #f5f7fb;
|
||||
color: #172033;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
input,
|
||||
button {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 20px 16px 32px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 16px;
|
||||
padding: 18px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: #e60012;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #ffffff;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.primary-button[disabled],
|
||||
.secondary-button[disabled],
|
||||
.ghost-button[disabled] {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
color: #334155;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px 12px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
export interface ApiPayload<T> {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string;
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
export type RoleKey = 'user' | 'supervisor' | 'candidate';
|
||||
|
||||
export interface ToolItem {
|
||||
title: string;
|
||||
desc: string;
|
||||
tone?: string;
|
||||
}
|
||||
|
||||
export interface PrimaryTask {
|
||||
badge: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
progressText: string;
|
||||
progress: number;
|
||||
cta: string;
|
||||
}
|
||||
|
||||
export interface TodoItem extends ToolItem {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ReviewTask {
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface RoleHome {
|
||||
role: RoleKey;
|
||||
roleLabel: string;
|
||||
greeting: string;
|
||||
location: string;
|
||||
toolTitle?: string;
|
||||
recommend?: string;
|
||||
primary?: PrimaryTask;
|
||||
review?: ReviewTask;
|
||||
tools?: ToolItem[];
|
||||
todos?: TodoItem[];
|
||||
}
|
||||
|
||||
export interface KnowledgeSnippet {
|
||||
docTitle: string;
|
||||
content: string;
|
||||
source?: string;
|
||||
fragmentId?: number | null;
|
||||
}
|
||||
|
||||
export interface KnowledgeDoc {
|
||||
title: string;
|
||||
category?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeSearchResponse {
|
||||
queryText: string;
|
||||
category: string;
|
||||
answer: string;
|
||||
reference: string;
|
||||
docs: KnowledgeDoc[];
|
||||
snippets: KnowledgeSnippet[];
|
||||
training: string[];
|
||||
records: unknown[];
|
||||
keyPoints?: string[];
|
||||
cautions?: string[];
|
||||
}
|
||||
|
||||
export type AnswerFeedbackVerdict = 'up' | 'down';
|
||||
|
||||
export interface SummaryCardStep {
|
||||
label: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export interface SummaryCardObjection {
|
||||
concern: string;
|
||||
response: string;
|
||||
}
|
||||
|
||||
export interface SummaryCardResponse {
|
||||
title: string;
|
||||
steps: SummaryCardStep[];
|
||||
objections: SummaryCardObjection[];
|
||||
scripts: string[];
|
||||
reminders: string[];
|
||||
}
|
||||
|
||||
export type PracticeRole = 'customer' | 'trainee' | 'coach';
|
||||
|
||||
export interface PracticeTurn {
|
||||
role: PracticeRole;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PracticeStartResponse {
|
||||
sessionId: string;
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
trainee: string;
|
||||
customerText: string;
|
||||
emotion?: number;
|
||||
trust?: number;
|
||||
customerAudioUrl?: string;
|
||||
}
|
||||
|
||||
export interface PracticePrepCard {
|
||||
keyPoints: string[];
|
||||
redLines: string[];
|
||||
scripts: string[];
|
||||
promptVersion: string;
|
||||
}
|
||||
|
||||
export interface PracticeTurnResponse {
|
||||
customerText: string;
|
||||
customerAudioUrl?: string;
|
||||
emotion?: number;
|
||||
trust?: number;
|
||||
coachHint?: string;
|
||||
redFlag?: boolean;
|
||||
roundIndex: number;
|
||||
finished: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PracticeDimensionScore {
|
||||
name: string;
|
||||
score: number;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface PracticeFinishResponse {
|
||||
totalScore: number;
|
||||
score?: PracticeDimensionScore[];
|
||||
mentorRewrite: string;
|
||||
aiComment: string;
|
||||
trustCurve?: number[];
|
||||
}
|
||||
|
||||
export interface PracticeRecord {
|
||||
id?: number;
|
||||
sessionId?: string;
|
||||
extPartyId?: string;
|
||||
time: string;
|
||||
trainee: string;
|
||||
scene: string;
|
||||
score: number;
|
||||
status: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface ReviewDialogue {
|
||||
role: string;
|
||||
label: string;
|
||||
text: string;
|
||||
audioUrl?: string;
|
||||
}
|
||||
|
||||
export interface ReviewAnnotation {
|
||||
turnIndex: number;
|
||||
sourceText: string;
|
||||
issue: string;
|
||||
rewrite: string;
|
||||
sopPoint: string;
|
||||
}
|
||||
|
||||
export interface ReviewDetail extends PracticeRecord {
|
||||
mentorRewrite: string;
|
||||
aiComment: string;
|
||||
reviewAdvice?: string;
|
||||
incentivePoint?: number;
|
||||
scoreItems: CompetencyDimension[];
|
||||
dialogue: ReviewDialogue[];
|
||||
annotations: ReviewAnnotation[];
|
||||
}
|
||||
|
||||
export interface ReviewAdviceRequest {
|
||||
reviewer?: string;
|
||||
advice?: string;
|
||||
incentivePoint?: number;
|
||||
nextScenarioId?: string;
|
||||
nextReason?: string;
|
||||
}
|
||||
|
||||
export interface PracticeAssignment {
|
||||
id: number;
|
||||
extPartyId: string;
|
||||
traineeName: string;
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
source: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
createTime: string;
|
||||
question?: string;
|
||||
referenceAnswer?: string;
|
||||
userAnswer?: string;
|
||||
feedback?: string;
|
||||
correct?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PracticeAssignmentRequest {
|
||||
extPartyId: string;
|
||||
traineeName?: string;
|
||||
scenarioId?: string;
|
||||
scenarioName?: string;
|
||||
source?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DailyDrillAnswerResponse {
|
||||
id: number;
|
||||
status: string;
|
||||
correct: boolean;
|
||||
score: number;
|
||||
feedback: string;
|
||||
referenceAnswer: string;
|
||||
}
|
||||
|
||||
export interface PracticeAlert {
|
||||
type: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
severity: string;
|
||||
extPartyId?: string;
|
||||
}
|
||||
|
||||
export interface CandidateMaterial {
|
||||
id: number | string;
|
||||
ossId: number | string;
|
||||
fileName: string;
|
||||
materialType: string;
|
||||
fileSize: number;
|
||||
status: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface CandidateSelectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CaseSelectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CaseTag {
|
||||
text: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface CaseSummaryItem {
|
||||
label: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CaseRecord {
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
time: string;
|
||||
owner: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CaseUploadResponse {
|
||||
caseId: string;
|
||||
fileName: string;
|
||||
transcript: string;
|
||||
tags: CaseTag[];
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface CaseOrganizeResponse {
|
||||
caseId: string;
|
||||
summary: CaseSummaryItem[];
|
||||
tags: CaseTag[];
|
||||
aiSummary: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface CaseCurateResponse {
|
||||
selectedCaseIds: string[];
|
||||
records: CaseRecord[];
|
||||
sampleHint: string;
|
||||
}
|
||||
|
||||
export interface InterviewQuestion {
|
||||
questionId: string;
|
||||
seq: number;
|
||||
questionText: string;
|
||||
point: string;
|
||||
scenario: string;
|
||||
sampleAnswer: string;
|
||||
}
|
||||
|
||||
export interface InterviewStartResponse {
|
||||
sessionId: string;
|
||||
candidateId: string;
|
||||
candidateName: string;
|
||||
positionCode: string;
|
||||
questions: InterviewQuestion[];
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface InterviewDimension {
|
||||
label: string;
|
||||
value: number | string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface InterviewRecord {
|
||||
time: string;
|
||||
candidate: string;
|
||||
position: string;
|
||||
score: number;
|
||||
result: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface InterviewFinishResponse {
|
||||
totalScore: number;
|
||||
suggestion: string;
|
||||
result: string;
|
||||
aiSummary: string;
|
||||
evidence: string;
|
||||
dimensions: InterviewDimension[];
|
||||
records: InterviewRecord[];
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface CompetencyDimension {
|
||||
label: string;
|
||||
value: number | string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface GrowthStage {
|
||||
level: string;
|
||||
progress: number;
|
||||
status: string;
|
||||
evidence: string;
|
||||
}
|
||||
|
||||
export interface CompetencyProfile {
|
||||
score: number;
|
||||
completed: number;
|
||||
pendingReview: number;
|
||||
trainingMinutes?: number;
|
||||
contribution?: number;
|
||||
assessmentScore?: number;
|
||||
aiLevel?: string;
|
||||
dimensions: CompetencyDimension[];
|
||||
growthPath?: GrowthStage[];
|
||||
}
|
||||
|
||||
export interface PromotionEvidence {
|
||||
extPartyId: string;
|
||||
currentLevel: string;
|
||||
assessmentScore: number;
|
||||
trainingMinutes: number;
|
||||
contribution: number;
|
||||
completed: number;
|
||||
pendingReview: number;
|
||||
dimensions: CompetencyDimension[];
|
||||
latestRecords: PracticeRecord[];
|
||||
assignments: PracticeAssignment[];
|
||||
evidenceItems: string[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["@dcloudio/types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import uniPlugin from '@dcloudio/vite-plugin-uni';
|
||||
|
||||
const uni = typeof uniPlugin === 'function' ? uniPlugin : uniPlugin.default;
|
||||
const devPort = Number(process.env.PORT) || 5175;
|
||||
const devHost = process.env.HOST || '0.0.0.0';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
server: {
|
||||
host: devHost,
|
||||
port: devPort,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/dev-api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/dev-api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user