diff --git a/mobile-uni/src/pages.json b/mobile-uni/src/pages.json
index b4447641..c3df00c2 100644
--- a/mobile-uni/src/pages.json
+++ b/mobile-uni/src/pages.json
@@ -25,6 +25,36 @@
"navigationBarTitleText": "问师傅"
}
},
+ {
+ "path": "pages/user/assistant/index",
+ "style": {
+ "navigationBarTitleText": "个人AI助理"
+ }
+ },
+ {
+ "path": "pages/user/assistant/library",
+ "style": {
+ "navigationBarTitleText": "我的资料库"
+ }
+ },
+ {
+ "path": "pages/user/assistant/capture",
+ "style": {
+ "navigationBarTitleText": "收藏资料"
+ }
+ },
+ {
+ "path": "pages/user/assistant/item",
+ "style": {
+ "navigationBarTitleText": "资料详情"
+ }
+ },
+ {
+ "path": "pages/user/assistant/sessions",
+ "style": {
+ "navigationBarTitleText": "历史会话"
+ }
+ },
{
"path": "pages/user/profile/index",
"style": {
diff --git a/mobile-uni/src/pages/user/assistant/capture.vue b/mobile-uni/src/pages/user/assistant/capture.vue
new file mode 100644
index 00000000..2d645f6b
--- /dev/null
+++ b/mobile-uni/src/pages/user/assistant/capture.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 网页内容由服务端安全抓取;内网地址或被拦截地址不会保存。
+
+
+
+ {{ Math.ceil(selectedFile.size / 1024) }}KB
+
+
+ {{ message }}
+
+
+
+
+
+
+
diff --git a/mobile-uni/src/pages/user/assistant/index.vue b/mobile-uni/src/pages/user/assistant/index.vue
new file mode 100644
index 00000000..daefd6a1
--- /dev/null
+++ b/mobile-uni/src/pages/user/assistant/index.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+ 回答范围
+
+
+
+
+ 默认只查询个人资料;企业知识不会自动开启。
+
+
+
+
+
+ {{ message }}
+
+
+
+
+
+
+
+
+
+ 回答
+ {{ answer.answer }}
+
+ 引用依据
+
+
+ 本次回答没有可展示的引用,不补造来源。
+
+
+
+
+
+
+
diff --git a/mobile-uni/src/pages/user/assistant/item.vue b/mobile-uni/src/pages/user/assistant/item.vue
new file mode 100644
index 00000000..d3bccec6
--- /dev/null
+++ b/mobile-uni/src/pages/user/assistant/item.vue
@@ -0,0 +1,53 @@
+
+
+
+ 加载中…
+ {{ message }}
+
+
+ {{ sourceText(item.sourceType) }}{{ statusText(item.status) }}
+ {{ item.title || '未命名资料' }}
+ 收藏时间:{{ item.capturedAt || '未记录' }}
+ 网页来源:{{ item.originalUrl }}
+ 标签:{{ item.tags.join('、') }}
+
+
+ 处理状态
+ {{ statusDescription(item.status) }}
+ {{ item.errorMessage }}
+
+
+
+ 内容摘要{{ item.summary }}
+
+ 删除资料
+ 删除后,附件、解析正文和搜索索引会清除,且无法恢复。
+
+
+ {{ message }}
+
+
+
+
+
+
+
diff --git a/mobile-uni/src/pages/user/assistant/library.vue b/mobile-uni/src/pages/user/assistant/library.vue
new file mode 100644
index 00000000..f1b9d29b
--- /dev/null
+++ b/mobile-uni/src/pages/user/assistant/library.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
+ 已用 {{ formatBytes(space.usedBytes) }}{{ space.itemCount }} 条
+
+ 空间上限 {{ formatBytes(space.quotaBytes) }}
+
+
+
+
+
+ {{ sourceLabel }}
+ {{ statusLabel }}
+
+
+
+
+ 加载中…
+ {{ message }}
+ 还没有个人资料,先收藏一条文字、文件或网页。
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile-uni/src/pages/user/assistant/sessions.vue b/mobile-uni/src/pages/user/assistant/sessions.vue
new file mode 100644
index 00000000..59e67927
--- /dev/null
+++ b/mobile-uni/src/pages/user/assistant/sessions.vue
@@ -0,0 +1,44 @@
+
+
+
+ 加载中…
+ {{ message }}
+
+
+
+ {{ chat.role === 'USER' ? '我' : '个人助理' }}
+ {{ chat.content }}
+
+ {{ citation.domain === 'ENTERPRISE' ? '企业 SOP' : '我的资料' }} · {{ citation.title }}
+
+
+
+ 暂无历史会话
+
+
+
+
+
+
+
+
+
diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue
index 698453ea..ada91dd5 100644
--- a/mobile-uni/src/pages/user/sop/index.vue
+++ b/mobile-uni/src/pages/user/sop/index.vue
@@ -8,6 +8,18 @@
先把问题问清楚,再按需生成总结卡,不伪造分类能力。
+
+
+ 个人助理 · 我的资料
+ 收藏个人文字、文件和网页后提问;与当前企业 SOP 问答分开管理。
+
+
+
+
+
+ 当前页面:企业 SOP · 个人助理默认:我的资料
+
+
@@ -340,6 +352,14 @@ const closeSummary = () => {
summaryCard.value = null;
};
+const openPersonalAssistant = () => {
+ uni.navigateTo({ url: '/pages/user/assistant/index' });
+};
+
+const openPersonalCapture = () => {
+ uni.navigateTo({ url: '/pages/user/assistant/capture' });
+};
+
const goLogin = () => {
rememberLoginRedirect('/pages/user/sop/index');
uni.navigateTo({ url: '/pages/auth/login/index' });
@@ -404,6 +424,43 @@ onShow(() => {
margin-bottom: 12px;
}
+.assistant-entry {
+ margin-top: 16px;
+ padding: 16px;
+}
+
+.assistant-entry-title,
+.assistant-entry-desc,
+.assistant-domain-note {
+ display: block;
+}
+
+.assistant-entry-title {
+ color: var(--ink);
+ font-weight: 900;
+}
+
+.assistant-entry-desc,
+.assistant-domain-note {
+ margin-top: 6px;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.assistant-entry-actions {
+ display: grid;
+ margin-top: 12px;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.assistant-entry-actions button {
+ min-width: 0;
+ padding: 0 8px;
+ font-size: 13px;
+}
+
.sop-head .page-title {
color: #1f2b3d;
font-size: 31px;
diff --git a/mobile-uni/src/services/personal-assistant.ts b/mobile-uni/src/services/personal-assistant.ts
new file mode 100644
index 00000000..37063d4b
--- /dev/null
+++ b/mobile-uni/src/services/personal-assistant.ts
@@ -0,0 +1,155 @@
+import type {
+ PersonalAskResponse,
+ PersonalItem,
+ PersonalItemCreated,
+ PersonalItemSourceType,
+ PersonalKnowledgeScope,
+ PersonalPage,
+ PersonalSession,
+ PersonalSessionDetail,
+ PersonalSpace
+} from '@/types/api';
+import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
+
+export type KnowledgeScope = PersonalKnowledgeScope;
+
+export interface PersonalItemQuery {
+ pageNum?: number;
+ pageSize?: number;
+ status?: string;
+ sourceType?: PersonalItemSourceType | '';
+ dateFrom?: string;
+ dateTo?: string;
+ keyword?: string;
+}
+
+const queryString = (query: PersonalItemQuery) => {
+ const params = Object.entries(query)
+ .filter(([, value]) => value !== undefined && value !== '')
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
+ return params.length ? `?${params.join('&')}` : '';
+};
+
+export const askPersonalAssistant = (payload: {
+ sessionId?: number;
+ queryText: string;
+ scope: KnowledgeScope[];
+ dateFrom?: string;
+ dateTo?: string;
+ itemIds?: number[];
+ outputFormat?: 'ANSWER' | 'ACTION_PLAN' | 'OUTLINE';
+}) => apiRequest({
+ url: '/api/aihr/personal-assistant/ask',
+ method: 'POST',
+ data: payload,
+ timeout: 45000,
+ clearAuthOnUnauthorized: false
+});
+
+export const loadPersonalSpace = () => apiRequest({
+ url: '/api/aihr/personal-assistant/space',
+ method: 'GET'
+});
+
+export const loadPersonalItems = (query: PersonalItemQuery = {}) => apiRequest>({
+ url: `/api/aihr/personal-assistant/items${queryString(query)}`,
+ method: 'GET'
+});
+
+export const loadPersonalItem = (id: number) => apiRequest({
+ url: `/api/aihr/personal-assistant/items/${id}`,
+ method: 'GET'
+});
+
+export const createTextItem = (payload: { title?: string; content: string; capturedAt?: string; tags?: string[] }) =>
+ apiRequest({
+ url: '/api/aihr/personal-assistant/items/text',
+ method: 'POST',
+ data: payload
+ });
+
+export const createUrlItem = (payload: { url: string; title?: string; capturedAt?: string }) =>
+ apiRequest({
+ url: '/api/aihr/personal-assistant/items/url',
+ method: 'POST',
+ data: payload,
+ timeout: 45000
+ });
+
+export interface PersonalSelectedFile {
+ path: string;
+ name: string;
+ size: number;
+}
+
+export const choosePersonalFile = () => new Promise((resolve, reject) => {
+ uni.chooseFile({
+ count: 1,
+ type: 'all',
+ success: (result) => {
+ const files = Array.isArray(result.tempFiles) ? result.tempFiles : [];
+ const file = (files[0] || {}) as Partial;
+ const path = file.path || result.tempFilePaths?.[0] || '';
+ if (!path) {
+ reject(new Error('未获取到文件路径'));
+ return;
+ }
+ resolve({ path, name: file.name || path.split('/').pop() || 'personal-file', size: Number(file.size || 0) });
+ },
+ fail: () => reject(new Error('未选择文件'))
+ });
+});
+
+export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, capturedAt?: string) =>
+ new Promise((resolve, reject) => {
+ uni.uploadFile({
+ url: apiUrl('/api/aihr/personal-assistant/items/file'),
+ filePath: file.path,
+ name: 'file',
+ header: authHeaders(false),
+ formData: {
+ ...(title?.trim() ? { title: title.trim() } : {}),
+ ...(capturedAt ? { capturedAt } : {})
+ },
+ timeout: 45000,
+ success: (response) => {
+ try {
+ resolve(readTextPayload(response.statusCode, response.data));
+ } catch (error) {
+ reject(error);
+ }
+ },
+ fail: () => reject(new Error('文件收藏失败'))
+ });
+ });
+
+export const retryPersonalItem = (id: number) => apiRequest({
+ url: `/api/aihr/personal-assistant/items/${id}/retry`,
+ method: 'POST'
+});
+
+export const deletePersonalItem = (id: number) => apiRequest<{ cleanupJobId: number }>({
+ url: `/api/aihr/personal-assistant/items/${id}`,
+ method: 'DELETE',
+ timeout: 15000
+});
+
+export const downloadPersonalItem = (id: number) => apiRequest<{ url: string; expiresAt?: string }>({
+ url: `/api/aihr/personal-assistant/items/${id}/download-url`,
+ method: 'GET'
+});
+
+export const loadPersonalSessions = () => apiRequest({
+ url: '/api/aihr/personal-assistant/sessions',
+ method: 'GET'
+});
+
+export const loadPersonalSession = (id: number) => apiRequest({
+ url: `/api/aihr/personal-assistant/sessions/${id}`,
+ method: 'GET'
+});
+
+export const deletePersonalSession = (id: number) => apiRequest({
+ url: `/api/aihr/personal-assistant/sessions/${id}`,
+ method: 'DELETE'
+});
diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts
index 235f6f2d..89db89de 100644
--- a/mobile-uni/src/types/api.ts
+++ b/mobile-uni/src/types/api.ts
@@ -116,6 +116,84 @@ export interface SummaryCardResponse {
reminders: string[];
}
+export type PersonalKnowledgeScope = 'PERSONAL' | 'ENTERPRISE';
+export type PersonalItemSourceType = 'TEXT' | 'FILE' | 'URL';
+export type PersonalItemStatus = 'QUEUED' | 'PARSING' | 'READY' | 'FAILED' | 'DELETING' | 'DELETED';
+
+export interface PersonalCitation {
+ domain: PersonalKnowledgeScope;
+ sourceId: string;
+ title: string;
+ excerpt: string;
+ capturedAt?: string;
+}
+
+export interface PersonalAskResponse {
+ sessionId: number;
+ answer: string;
+ citations: PersonalCitation[];
+ model?: string;
+ promptVersion?: string;
+}
+
+export interface PersonalSpace {
+ spaceId: number;
+ status: string;
+ quotaBytes: number;
+ usedBytes: number;
+ itemCount: number;
+}
+
+export interface PersonalItem {
+ id: number;
+ sourceType: PersonalItemSourceType;
+ title: string;
+ originalUrl?: string;
+ mimeType?: string;
+ sizeBytes: number;
+ status: PersonalItemStatus;
+ errorCode?: string;
+ errorMessage?: string;
+ summary?: string;
+ tags?: string[];
+ capturedAt?: string;
+ parsedAt?: string;
+}
+
+export interface PersonalPage {
+ rows: T[];
+ total: number;
+ pageNum: number;
+ pageSize: number;
+}
+
+export interface PersonalItemCreated {
+ itemId: number;
+ status: PersonalItemStatus;
+ duplicateOf?: number;
+}
+
+export interface PersonalSession {
+ id: number;
+ title: string;
+ defaultScope: string;
+ updatedAt?: string;
+}
+
+export interface PersonalChatMessage {
+ id: number;
+ role: string;
+ content: string;
+ citations: PersonalCitation[];
+ createdAt?: string;
+}
+
+export interface PersonalSessionDetail {
+ id: number;
+ title: string;
+ messages: PersonalChatMessage[];
+}
+
export type PracticeRole = 'customer' | 'trainee' | 'coach';
export interface PracticeTurn {
diff --git a/mobile-uni/tests/personal-assistant.test.mjs b/mobile-uni/tests/personal-assistant.test.mjs
new file mode 100644
index 00000000..c806395f
--- /dev/null
+++ b/mobile-uni/tests/personal-assistant.test.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+
+const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
+
+test('个人助理 API 固定使用登录态且不接收 owner 参数', async () => {
+ const client = await source('../src/services/personal-assistant.ts');
+ assert.match(client, /apiRequest/);
+ assert.match(client, /\/api\/aihr\/personal-assistant\/ask/);
+ assert.match(client, /\/api\/aihr\/personal-assistant\/items\/file/);
+ assert.match(client, /uni\.uploadFile/);
+ assert.doesNotMatch(client, /ownerUserId|tenantId/);
+});
+
+test('问答首页默认只查询个人资料并展示真实引用域', async () => {
+ const page = await source('../src/pages/user/assistant/index.vue');
+ assert.match(page, /ref\(\['PERSONAL'\]\)/);
+ assert.match(page, /我的资料/);
+ assert.match(page, /企业知识按岗位权限/);
+ assert.match(page, /企业 SOP/);
+ assert.match(page, /外部网页/);
+ assert.match(page, /citation\.domain/);
+ assert.match(page, /收藏资料/);
+ assert.doesNotMatch(page, /ownerUserId|tenantId/);
+});
+
+test('资料库和收藏页覆盖分页筛选以及三种真实收藏方式', async () => {
+ const [library, capture] = await Promise.all([
+ source('../src/pages/user/assistant/library.vue'),
+ source('../src/pages/user/assistant/capture.vue')
+ ]);
+ assert.match(library, /pageNum/);
+ assert.match(library, /sourceType/);
+ assert.match(library, /status/);
+ assert.match(library, /loadPersonalItems/);
+ assert.match(capture, /TEXT/);
+ assert.match(capture, /FILE/);
+ assert.match(capture, /URL/);
+ assert.match(capture, /createTextItem/);
+ assert.match(capture, /uploadPersonalFile/);
+ assert.match(capture, /createUrlItem/);
+});
+
+test('个人资料详情包含全部状态、重试、下载和明确删除提示', async () => {
+ const page = await source('../src/pages/user/assistant/item.vue');
+ assert.match(page, /QUEUED/);
+ assert.match(page, /PARSING/);
+ assert.match(page, /READY/);
+ assert.match(page, /FAILED/);
+ assert.match(page, /DELETING/);
+ assert.match(page, /DELETED/);
+ assert.match(page, /retryPersonalItem/);
+ assert.match(page, /deletePersonalItem/);
+ assert.match(page, /downloadPersonalItem/);
+ assert.match(page, /附件、解析正文和搜索索引/);
+});
+
+test('会话页覆盖列表、详情和删除且不伪造会话', async () => {
+ const page = await source('../src/pages/user/assistant/sessions.vue');
+ assert.match(page, /loadPersonalSessions/);
+ assert.match(page, /loadPersonalSession/);
+ assert.match(page, /deletePersonalSession/);
+ assert.match(page, /暂无历史会话/);
+});
+
+test('个人助理路由已注册且企业 SOP 问答保留独立入口', async () => {
+ const [pages, sop] = await Promise.all([
+ source('../src/pages.json'),
+ source('../src/pages/user/sop/index.vue')
+ ]);
+ for (const route of ['index', 'library', 'capture', 'item', 'sessions']) {
+ assert.match(pages, new RegExp(`pages/user/assistant/${route}`));
+ }
+ assert.match(sop, /个人助理/);
+ assert.match(sop, /我的资料/);
+ assert.match(sop, /企业 SOP/);
+ assert.match(sop, /\/pages\/user\/assistant\/index/);
+});