feat(aihr): complete uni-app role workflows

This commit is contained in:
2026-07-09 10:42:20 +08:00
parent e4070c8b8c
commit 678543e8a5
73 changed files with 15696 additions and 134 deletions
+25 -22
View File
@@ -3,7 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FRONTEND_URL="${FRONTEND_URL:-http://127.0.0.1:5173}"
MOBILE_URL="${MOBILE_URL:-http://127.0.0.1:5174}"
MOBILE_URL="${MOBILE_URL:-https://wygj-mobile-uni.localhost}"
cd "$ROOT_DIR"
@@ -26,7 +26,7 @@ check_url() {
local name="$1"
local url="$2"
local code
code="$(curl -sS -o /tmp/wygj-demo-check.body -w '%{http_code}' "$url")"
code="$(curl -k -sS -o /tmp/wygj-demo-check.body -w '%{http_code}' "$url")"
[[ "$code" == "200" ]] || fail "$name returned HTTP $code"
echo "OK: $name"
}
@@ -75,7 +75,7 @@ check_pilot_samples() {
need curl
check_url "frontend" "$FRONTEND_URL/"
check_url "mobile" "$MOBILE_URL/h5/user"
check_url "mobile-uni" "$MOBILE_URL/h5/"
tenant_payload="$(curl -fsS "$FRONTEND_URL/dev-api/auth/tenant/list")"
[[ "$tenant_payload" == *'"tenantId":"000000"'* ]] || fail "tenant list missing tenant 000000"
@@ -91,20 +91,23 @@ contains frontend/src/router/index.ts "/knowledge/cases"
contains frontend/src/router/index.ts "/knowledge/sop"
contains frontend/src/router/index.ts "/system/model"
contains mobile/src/App.vue "早上好,王敏"
contains mobile/src/App.vue "AI结构化面试"
contains mobile/src/App.vue "团队今日动态"
contains mobile/src/App.vue "sendSmsCode"
contains mobile/src/App.vue "/dev-api/resource/sms/code"
contains mobile/src/App.vue "/dev-api/auth/mobile/sms-login"
contains mobile/src/App.vue "fetchMobileHome"
contains mobile/src/App.vue "/dev-api/api/aihr/mobile/home/"
contains mobile/src/App.vue "startMobilePractice"
contains mobile/src/App.vue "/dev-api/api/train/practice/start"
contains mobile/src/App.vue "训练完成,主管端待复盘已更新"
contains mobile/src/App.vue "/dev-api/api/aihr/mobile/practice/history"
contains mobile/src/App.vue "competencyProfile"
contains mobile/package.json "aihr-mobile"
contains mobile-uni/package.json "aihr-mobile-uni"
contains mobile-uni/src/pages.json "pages/user/today/index"
contains mobile-uni/src/pages.json "pages/supervisor/index/index"
contains mobile-uni/src/pages.json "pages/candidate/index/index"
contains mobile-uni/src/pages/auth/login/index.vue "/resource/sms/code"
contains mobile-uni/src/pages/auth/login/index.vue "/auth/mobile/sms-login"
contains mobile-uni/src/services/home.ts "/api/aihr/mobile/home/"
contains mobile-uni/src/services/practice.ts "/api/train/practice/start"
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/history"
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/profile/promotion-evidence"
contains mobile-uni/src/pages/user/practice/index.vue "训练完成,主管端待复盘已更新"
contains mobile-uni/src/pages/user/profile/index.vue "贡献度"
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/answer-feedback"
contains mobile-uni/src/services/knowledge.ts "/api/knowledge/summary-card"
contains mobile-uni/src/services/case.ts "/api/knowledge/case/upload"
contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
contains frontend/src/views/recruit/interview.vue "建议复试"
contains frontend/src/views/train/practice.vue "导师改写"
contains frontend/src/views/knowledge/cases.vue "真实语音"
@@ -133,21 +136,21 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java 'firstNonBlank(position, "生活顾问")'
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "normalizeSearchSource"
contains frontend/src/views/train/practice.vue "practice_help"
contains mobile/src/App.vue "practice_help"
contains mobile-uni/src/pages/user/practice/index.vue "loadHelp"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "turnEvidence"
contains mobile/src/App.vue "新员工每日三题"
contains mobile-uni/src/pages/user/practice/index.vue "每日三题"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "pilot_metric_ready"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "incentive_point"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrModelSeedService.java "cost-guard-fallback"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSpeechService.java "Duration.ofSeconds(5)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMobileSeedService.java "待复盘对练"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews"
contains mobile/src/App.vue "贡献度加分"
contains mobile-uni/src/pages/supervisor/index/index.vue "人工贡献分"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrMobileController.java "/practice/reviews/{id}/reviewed"
contains mobile/src/App.vue "标记已复盘"
contains mobile-uni/src/pages/supervisor/index/index.vue "标记已复盘"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "source = 'retry'"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "maskSensitiveText"
contains mobile/src/App.vue "submitDailyDrill"
contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/assignments"
contains frontend/src/api/aihr/case.ts "/api/knowledge/case/upload"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCaseController.java "/api/knowledge/case"
contains frontend/src/api/aihr/sop.ts "/api/knowledge/search"
+21 -5
View File
@@ -2,12 +2,28 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
if [[ -f "$ROOT_DIR/.env.local" ]]; then
set -a
# shellcheck disable=SC1091
source "$ROOT_DIR/.env.local"
set +a
load_env_file() {
local env_file="$1"
if [[ -f "$env_file" ]]; then
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
fi
}
load_env_file "$ROOT_DIR/.env.local"
if [[ -f "$ROOT_DIR/backend/.env" ]]; then
load_env_file "$ROOT_DIR/backend/.env"
if [[ -n "${client_id:-}" ]]; then
export AIHR_ORG_SYNC_CLIENT_ID="$client_id"
fi
if [[ -n "${client_secret:-}" ]]; then
export AIHR_ORG_SYNC_CLIENT_SECRET="$client_secret"
fi
fi
cd "$ROOT_DIR/backend"
+4 -3
View File
@@ -8,7 +8,7 @@ usage() {
cat <<'EOF'
Usage: ./scripts/dev.sh [start|stop|restart|status|--reset]
Starts MySQL, Redis, MinIO, Qdrant, backend, admin frontend and mobile frontend.
Starts MySQL, Redis, MinIO, Qdrant, backend, admin frontend and legacy mobile fallback.
start start local demo services
stop stop backend and frontend dev processes
restart stop backend and frontend, then start again
@@ -148,9 +148,10 @@ PIDS+=("$!")
echo "Backend: http://127.0.0.1:8080/"
echo "Admin: http://127.0.0.1:5173/"
echo "Mobile: http://127.0.0.1:5174/h5/user"
echo "Legacy mobile fallback: http://127.0.0.1:5174/h5/user"
echo "Current user side: https://wygj-mobile-uni.localhost/h5/ (run: portless)"
echo "Qdrant: http://127.0.0.1:6333/"
echo "Press Ctrl+C to stop backend, admin frontend and mobile frontend."
echo "Press Ctrl+C to stop backend, admin frontend and legacy mobile frontend."
while :; do
for pid in "${PIDS[@]}"; do
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const args = new Map(process.argv.slice(2).map((arg) => {
const [key, ...rest] = arg.replace(/^--/, '').split('=');
return [key, rest.join('=') || 'true'];
}));
const baseUrl = args.get('base-url') || process.env.AIHR_BASE_URL || 'http://127.0.0.1:8080';
const file = path.resolve(root, args.get('file') || 'docs/20260708/发布会演示候选题单.md');
const prefixes = (args.get('prefix') || 'Q').split(',').map((item) => item.trim()).filter(Boolean);
const limit = Number(args.get('limit') || 0);
function readEnv(filePath) {
const env = {};
const text = fs.readFileSync(filePath, 'utf8');
for (const line of text.split(/\r?\n/)) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (!match) continue;
env[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
}
return env;
}
function pem(label, body) {
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
}
const frontendEnv = readEnv(path.join(root, 'frontend/.env.development'));
const clientid = process.env.AIHR_CLIENT_ID || frontendEnv.VITE_APP_CLIENT_ID;
const requestPublicKey = pem('PUBLIC KEY', frontendEnv.VITE_APP_RSA_PUBLIC_KEY);
const responsePrivateKey = pem('PRIVATE KEY', frontendEnv.VITE_APP_RSA_PRIVATE_KEY);
function aesEncrypt(text, keyText) {
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
cipher.setAutoPadding(true);
return Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]).toString('base64');
}
function aesDecrypt(base64Text, key) {
const decipher = crypto.createDecipheriv('aes-256-ecb', key, null);
decipher.setAutoPadding(true);
return Buffer.concat([decipher.update(Buffer.from(base64Text, 'base64')), decipher.final()]).toString('utf8');
}
function encryptPayload(payload) {
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
const encryptedBody = aesEncrypt(JSON.stringify(payload), keyText);
const keyBase64 = Buffer.from(keyText, 'utf8').toString('base64');
const encryptedKey = crypto.publicEncrypt(
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(keyBase64, 'utf8')
).toString('base64');
return { encryptedBody, encryptedKey };
}
function decryptResponse(text, encryptedKey) {
if (!encryptedKey) return JSON.parse(text);
const keyBase64 = crypto.privateDecrypt(
{ key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(encryptedKey, 'base64')
).toString('utf8');
return JSON.parse(aesDecrypt(text, Buffer.from(keyBase64, 'base64')));
}
async function request(method, endpoint, body, token, encrypted = false) {
const headers = { clientid, 'Content-Language': 'zh_CN' };
let payload;
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) {
headers['Content-Type'] = 'application/json;charset=utf-8';
if (encrypted) {
const encryptedPayload = encryptPayload(body);
payload = encryptedPayload.encryptedBody;
headers['encrypt-key'] = encryptedPayload.encryptedKey;
} else {
payload = JSON.stringify(body);
}
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 70000);
try {
const response = await fetch(`${baseUrl}${endpoint}`, { method, headers, body: payload, signal: controller.signal });
const text = await response.text();
const data = encrypted ? decryptResponse(text, response.headers.get('encrypt-key')) : JSON.parse(text);
return { status: response.status, data };
} finally {
clearTimeout(timeout);
}
}
async function login() {
if (process.env.AIHR_VERIFY_TOKEN) return process.env.AIHR_VERIFY_TOKEN;
const codeResponse = await request('GET', '/auth/code');
const uuid = codeResponse.data?.data?.uuid;
if (!uuid) throw new Error('captcha uuid missing');
const redisKey = `global:captcha_codes:${uuid}`;
const redisValue = execFileSync(
'docker',
['exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', '--raw', 'get', redisKey],
{ encoding: 'utf8' }
).trim().split('\n').pop().replace(/^"|"$/g, '');
const loginResponse = await request('POST', '/auth/login', {
tenantId: '000000',
username: process.env.AIHR_VERIFY_USER || 'admin',
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
rememberMe: false,
code: redisValue,
uuid,
clientId: clientid,
grantType: 'password'
}, undefined, true);
const token = loginResponse.data?.data?.access_token;
if (!token) throw new Error(`login failed: ${loginResponse.data?.msg || loginResponse.status}`);
return token;
}
function parseQuestions(markdown) {
const rows = [];
for (const line of markdown.split(/\r?\n/)) {
if (!line.startsWith('|')) continue;
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
if (cells.length < 5 || !/^[A-Z]\d+$/u.test(cells[0])) continue;
if (!prefixes.some((prefix) => cells[0].startsWith(prefix))) continue;
rows.push({
id: cells[0],
scene: cells[1],
question: cells[2],
sources: [...cells[3].matchAll(/`([^`]+)`/g)].map((match) => match[1])
});
}
return limit > 0 ? rows.slice(0, limit) : rows;
}
function hasExpectedSource(result, expectedSources) {
if (!expectedSources.length) return true;
const haystack = [
...(result.docs || []).map((item) => item.title || item.docName || item.name || ''),
...(result.snippets || []).map((item) => item.title || item.docName || item.name || '')
].join('\n');
return expectedSources.some((source) => haystack.includes(source.replace(/\s+/g, ' ').trim()));
}
const questions = parseQuestions(fs.readFileSync(file, 'utf8'));
if (!questions.length) {
console.error(`FAIL: no questions found in ${file} for prefix ${prefixes.join(',')}`);
process.exit(1);
}
const token = await login();
const vector = await request('GET', '/api/knowledge/doc/vector-index-status', undefined, token);
const vectorStatus = vector.data?.data;
if (!vectorStatus?.matched) {
console.error(`FAIL: vector index mismatch: ${vectorStatus?.message || 'unknown'}`);
process.exit(1);
}
let failed = 0;
let warned = 0;
console.log(`VERIFY ${questions.length} question(s), prefix=${prefixes.join(',')}, base=${baseUrl}`);
for (const item of questions) {
const response = await request('POST', '/api/knowledge/search', {
queryText: item.question,
category: 'sop',
position: '生活顾问'
}, token);
const data = response.data?.data || {};
const docs = data.docs || [];
const snippets = data.snippets || [];
const ok = response.status === 200 && response.data?.code === 200 && Boolean((data.answer || '').trim()) && (docs.length || snippets.length);
const sourceOk = hasExpectedSource(data, item.sources);
if (!ok) {
failed += 1;
console.log(`FAIL ${item.id} ${item.scene}: ${item.question}`);
continue;
}
if (!sourceOk) {
warned += 1;
console.log(`WARN ${item.id} ${item.scene}: answered, but top evidence missed listed source`);
} else {
console.log(`OK ${item.id} ${item.scene}`);
}
}
console.log(`SUMMARY ok=${questions.length - failed}/${questions.length} warnings=${warned} vector=${vectorStatus.embeddedFragments}/${vectorStatus.fragments}`);
process.exit(failed ? 1 : 0);