Merge remote-tracking branch 'origin/master'

# Conflicts:
#	.env.production
This commit is contained in:
2026-11-05 16:52:52 +08:00
119 changed files with 14008 additions and 2439 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
VITE_APP_NAME=后台管理系统 VITE_APP_NAME=后台管理系统
VITE_API_URL=http://127.0.0.1:10051/api VITE_API_URL=http://127.0.0.1:38927/api
#VITE_API_URL=https://tuanwei-api.gxwebsoft.com/api #VITE_API_URL=https://tuanwei-api.gxwebsoft.com/api
+3 -1
View File
@@ -1,2 +1,4 @@
VITE_APP_NAME=后台管理系统 VITE_APP_NAME=后台管理系统
VITE_API_URL=http://10.129.0.125:10051/api # 生产 API 必须与站点同协议:后台以 https 部署时,http 请求会被浏览器按
# 混合内容(mixed-content)整条拦掉,登录直接失败。
VITE_API_URL=https://twai-api.gxmu.edu.cn/api
+6688
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
allowBuilds:
'@parcel/watcher': true
'@vue-office/docx': true
'@vue-office/excel': true
core-js: true
es5-ext: true
esbuild: true
vue-demi: true
vue-echarts: true
@@ -2,14 +2,29 @@ export interface ActivityOutlineGenParams {
event_theme: string; event_theme: string;
people: string; people: string;
event_time: string; event_time: string;
/**
* 活动资金。这里保持 string 是**有意**的:表单输入框是自由文本
* (示例值形如「预算 5000 元,来源为校团委志愿服务专项经费」),
* 由后端 `normalizeEventFunding` 抽出数值再发给 Dify(上游该变量是 number)。
* 抽不出数值时后端降级为空串——上游接受空串,但不接受不可解析的文本。
*/
event_funding: string; event_funding: string;
event_type?: string; event_type?: string;
event_location?: string; event_location?: string;
event_meme?: string; event_meme?: string;
/** 以下 3 项为 Dify 必填变量,当前表单未采集;后端会补空串占位(见 issue 09)。 */
event_member?: string;
people_manage?: string;
tel_people?: string;
} }
export interface ActivityOutlineGenResponse { export interface ActivityOutlineGenResponse {
answer?: string; answer?: string;
/**
* 上游工作流状态。失败时为 `'failed'` —— 前端据此把失败文案与正文区分开
* (见 `@/utils/ai-result` 的 `isFailedPayload` 与 issue 10)。
*/
status?: string;
conversationId?: string; conversationId?: string;
messageId?: string; messageId?: string;
raw?: Record<string, any>; raw?: Record<string, any>;
+5
View File
@@ -28,6 +28,10 @@ export interface AiChatList {
updateTime?: string; updateTime?: string;
// //
title?: string; title?: string;
// 记录类型:chat / writing / image / creative / activity_outline
chatType?: string;
// 生成记录入参快照(JSON 字符串,可能为 null)
params?: string;
} }
/** /**
@@ -43,5 +47,6 @@ export interface AiChatListParam extends PageParam {
status?: number; status?: number;
deleted?: number; deleted?: number;
title?: string; title?: string;
chatType?: string;
keywords?: string; keywords?: string;
} }
+9 -1
View File
@@ -10,10 +10,18 @@ import type {
/** /**
* AI文件上传 * AI文件上传
*
* @param app 应用代号(后端按代号选择 Dify 应用凭证)。
* Dify 的 /files/upload 是**按应用鉴权**的:文件只能被上传时所用应用的工作流引用,
* 换个应用引用同一个 upload_file_id 会报 `Invalid upload file`。
* 所以喂给创意助手 file-list 变量的附件,必须用 app='creativeAssistant' 上传。
*/ */
export async function uploadAiFile(file: File) { export async function uploadAiFile(file: File, app?: string) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
if (app) {
formData.append('app', app);
}
const res = await request.post<ApiResult<FileRecord>>( const res = await request.post<ApiResult<FileRecord>>(
MODULES_API_URL + '/ai/file/upload', MODULES_API_URL + '/ai/file/upload',
formData formData
+30 -3
View File
@@ -3,21 +3,48 @@ import type { FileRecord } from '@/api/system/file/model';
export interface CreativeAssistantUploadFile { export interface CreativeAssistantUploadFile {
transfer_method: 'local_file'; transfer_method: 'local_file';
type: 'image' | 'document' | 'audio' | 'video'; type: 'image' | 'document' | 'audio' | 'video';
upload_file_id?: number; /**
* Dify /files/upload 返回的文件 id。
*
* 注意它是 **uuid 字符串**(不是本地 FileRecord 的自增 id);原先声明为 number,
* 与实际不符 —— 而且 Dify 按应用鉴权该 id,必须用同一应用的凭证上传才有效。
*/
upload_file_id?: string | number;
url: string; url: string;
} }
export interface CreativeAssistantRequest { export interface CreativeAssistantRequest {
user_content?: string; user_content?: string;
user_contentString?: string; user_contentString?: string;
/**
* 端上的模式。`image` 时后端会把 user_content 包装成一张「生成图片」的意图声明,
* 让上游「问题分类器」不把画面描述判成文案/脚本分支(见 issue 19)。
*/
mode?: 'chat' | 'writing' | 'image';
/**
* 参考素材。2026-10 起端上已不再发送:上游出图工具只做文生图,没有任何节点消费它
* (见 issue 16)。字段暂时保留,便于回退。
*/
user_files?: CreativeAssistantUploadFile[]; user_files?: CreativeAssistantUploadFile[];
} }
/**
* 工作流产出的一项文件。
*
* 正常是 { url, name, type } 这样的对象(后端已归一),但上游有时直接把
* 地址字符串塞进数组;端上按字符串兜一层,否则整条视频链路取不到链接
* (见禅道 bug #1:有视频不播放、复制链接为空)。
*/
export type CreativeFileItem = Partial<FileRecord> | string;
/** 文件列表:正常情况下是数组,退化时可能是单个对象或裸地址。 */
export type CreativeFileList = CreativeFileItem[] | CreativeFileItem;
export interface CreativeAssistantOutputs { export interface CreativeAssistantOutputs {
video_result?: Partial<FileRecord>[]; video_result?: CreativeFileList;
video_message?: string; video_message?: string;
Script_result?: string; Script_result?: string;
photo_files?: Partial<FileRecord>[]; photo_files?: CreativeFileList;
photo_message?: string; photo_message?: string;
} }
+3
View File
@@ -1,7 +1,10 @@
import type { FileRecord } from '@/api/system/file/model'; import type { FileRecord } from '@/api/system/file/model';
export interface ManuscriptGenParams { export interface ManuscriptGenParams {
/** 文稿模板;留空表示自由创作,后端会转「AI 创意助手」 */
docType?: string; docType?: string;
/** 自由创作时用户想写的内容,转创意助手后作为 user_content 下发 */
userContent?: string;
eventType?: string; eventType?: string;
theme?: string; theme?: string;
time?: string; time?: string;
+3 -1
View File
@@ -9,8 +9,10 @@ export interface Manuscript {
id?: number; id?: number;
// //
title?: string; title?: string;
// // 正文(HTML,历史上可能是 markdown / 纯文本)
content?: string; content?: string;
// 正文格式:html / markdown / text
contentFormat?: string;
// //
cover?: string; cover?: string;
// 是否删除, 0否, 1是 // 是否删除, 0否, 1是
+34
View File
@@ -0,0 +1,34 @@
import request from '@/utils/request';
import type { ApiResult } from '@/api';
/**
* 撤回申报:待审核 -> 草稿
*/
export async function withdrawDeclareRecord(module: string, id: number) {
const res = await request.post<ApiResult<unknown>>(
'/gxmu/declare-record/withdraw',
{ module, id }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/**
* 作废申报(等同强制放行,作废后申报人可重新申报)
*/
export async function voidDeclareRecord(
module: string,
id: number,
reason?: string
) {
const res = await request.post<ApiResult<unknown>>(
'/gxmu/declare-record/void',
{ module, id, reason }
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
+12
View File
@@ -36,6 +36,18 @@ export interface Declare {
createTime?: string; createTime?: string;
// 更新时间 // 更新时间
updateTime?: string; updateTime?: string;
// 是否允许重复提交:0否 1是
allowRepeat?: number;
// 最多同时进行中的申报条数
maxPending?: number;
// 当前用户在该项目下的最新申报状态:0草稿 1待审核 2通过 3驳回 4作废
myStatus?: number;
// 当前用户在该项目下的最新申报记录id
myRecordId?: number;
// 当前用户在该项目下的最新申报记录提交时间
mySubmitTime?: string;
// 当前用户在该项目下的最新申报记录状态备注
myStatusRemark?: string;
// 删除标记:0未删 1已删 // 删除标记:0未删 1已删
deleted?: number; deleted?: number;
// 是否可申报 // 是否可申报
+1 -1
View File
@@ -14,7 +14,7 @@ export interface ReviewList {
businessName?: string; businessName?: string;
// 主键 // 主键
pk?: number; pk?: number;
// 状态(0不通过 1通过) // 状态(0驳回 1通过)
status?: number; status?: number;
// 审核意见 // 审核意见
content?: string; content?: string;
+77
View File
@@ -0,0 +1,77 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { StudentRoster, StudentRosterParam } from './model';
/** 学生名册分页查询 */
export async function pageStudentRoster(params: StudentRosterParam) {
const res = await request.get<ApiResult<PageResult<StudentRoster>>>(
'/gxmu/student/page',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 学生名册详情 */
export async function getStudentRoster(id: number) {
const res = await request.get<ApiResult<StudentRoster>>(
'/gxmu/student/' + id
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 导出名册(返回已脱敏列表,由前端生成文件) */
export async function exportStudentRoster(params: StudentRosterParam) {
const res = await request.post<ApiResult<StudentRoster[]>>(
'/gxmu/student/export',
null,
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 自助认领(学号 + 姓名) */
export async function claimStudent(data: { xh: string; xm: string }) {
const res = await request.post<ApiResult<unknown>>(
'/gxmu/student/claim',
data
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 手动认领/改绑 */
export async function bindStudent(
id: number,
data?: { userId?: number; phone?: string }
) {
const res = await request.put<ApiResult<unknown>>(
`/gxmu/student/${id}/claim`,
data || {}
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** 解除认领 */
export async function unbindStudent(id: number) {
const res = await request.put<ApiResult<unknown>>(
`/gxmu/student/${id}/unclaim`
);
if (res.data.code === 0) {
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
+60
View File
@@ -0,0 +1,60 @@
/**
* 学生名册(开放平台同步)
*/
/** 学生名册记录 */
export interface StudentRoster {
id: number;
/** 学号 */
xh: string;
/** 姓名 */
xm?: string;
/** 性别码 */
xbm?: string;
/** 性别 */
sex?: string;
/** 民族码 */
mzm?: string;
/** 民族 */
nation?: string;
/** 政治面貌码 */
zzmmm?: string;
/** 政治面貌 */
politics?: string;
/** 身份证件号(后端已脱敏) */
sfzjh?: string;
/** 手机号(后端已脱敏) */
sjh?: string;
/** 平台班级码 */
bjm?: string;
/** 平台班号 */
bh?: string;
classId?: number;
className?: string;
collegeId?: number;
collegeName?: string;
dwh?: string;
zyh?: string;
zymc?: string;
/** 培养层次 */
pyccmc?: string;
/** 状态:1在校 0毕业 */
status?: number;
/** 已认领的账号ID */
userId?: number;
claimTime?: string;
syncTime?: string;
}
/** 名册查询参数 */
export interface StudentRosterParam {
page?: number;
limit?: number;
collegeId?: number;
classId?: number;
xh?: string;
xm?: string;
pyccmc?: string;
status?: number;
claimed?: boolean;
}
+66
View File
@@ -0,0 +1,66 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type {
SyncBatchStatus,
SyncRecord,
SyncRecordParam
} from './model';
/** 提交类接口统一返回批次号 */
async function submit(url: string) {
const res = await request.post<ApiResult<string>>(url);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 提交全部同步,返回批次号 */
export function syncAll() {
return submit('/gxmu/sync/all');
}
/** 提交部门同步,返回批次号 */
export function syncDept() {
return submit('/gxmu/sync/dept');
}
/** 提交教师同步,返回批次号 */
export function syncTeacher() {
return submit('/gxmu/sync/teacher');
}
/** 提交班级同步,返回批次号 */
export function syncClass() {
return submit('/gxmu/sync/class');
}
/** 提交学生同步,返回批次号 */
export function syncStudent() {
return submit('/gxmu/sync/student');
}
/**
* 查询当前(或最近一次)批次进度。
*
* 同步是异步的:学生全量约 9.8 万条、耗时 75~90 秒,提交后需轮询本接口看进度。
*/
export async function syncStatus() {
const res = await request.get<ApiResult<SyncBatchStatus>>('/gxmu/sync/status');
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 同步记录分页查询 */
export async function pageSyncRecord(params: SyncRecordParam) {
const res = await request.get<ApiResult<PageResult<SyncRecord>>>(
'/gxmu/sync/record/page',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+79
View File
@@ -0,0 +1,79 @@
/**
* 开放平台数据同步
*/
/** 单段同步结果(同步返回,异步改动后仅用于记录展示) */
export interface SyncResult {
/** 同步类型:dept/teacher/class/student */
syncType: string;
/** 成功条数 */
successCount: number;
/** 失败条数 */
failCount: number;
/** 跳过/停用条数 */
skipCount: number;
/** 是否成功 */
success: boolean;
/** 错误信息 */
errorMsg?: string;
}
/** 某一段的进度 */
export interface SyncSectionStatus {
/** 同步类型:dept/teacher/class/student */
syncType: string;
/** pending 待执行 / running 进行中 / success 成功 / fail 失败 */
state: 'pending' | 'running' | 'success' | 'fail';
successCount?: number;
failCount?: number;
skipCount?: number;
startTime?: string;
endTime?: string;
errorMsg?: string;
}
/** 批次进度 */
export interface SyncBatchStatus {
/** 是否有同步任务在执行 */
running: boolean;
batchId?: string;
triggerType?: string;
triggerUser?: string;
startTime?: string;
/** 正在执行的段 */
currentSection?: string;
/** 本次批次的各段状态 */
sections?: SyncSectionStatus[];
}
/** 同步记录 */
export interface SyncRecord {
id: number;
/** 批次号 */
batchId?: string;
/** 同步类型 */
syncType: string;
/** 触发方式:schedule/manual */
triggerType: string;
/** 触发人 */
triggerUser?: string;
/** 开始时间 */
startTime?: string;
/** 结束时间 */
endTime?: string;
successCount: number;
failCount: number;
skipCount: number;
/** 状态:0成功 1失败 2进行中 */
status: number;
/** 错误摘要 */
errorMsg?: string;
}
/** 同步记录查询参数 */
export interface SyncRecordParam {
page?: number;
limit?: number;
syncType?: string;
batchId?: string;
}
+28
View File
@@ -0,0 +1,28 @@
import request from '@/utils/request';
import type { ApiResult, PageResult } from '@/api';
import type { TeacherRoster, TeacherRosterParam } from './model';
/** 教师名册分页查询 */
export async function pageTeacherRoster(params: TeacherRosterParam) {
const res = await request.get<ApiResult<PageResult<TeacherRoster>>>(
'/gxmu/teacher/page',
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
/** 导出教师名册(返回列表,由前端生成文件;响应中不含手机号) */
export async function exportTeacherRoster(params: TeacherRosterParam) {
const res = await request.post<ApiResult<TeacherRoster[]>>(
'/gxmu/teacher/export',
null,
{ params }
);
if (res.data.code === 0 && res.data.data) {
return res.data.data;
}
return Promise.reject(new Error(res.data.message));
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 教师名册(开放平台同步)
*/
/** 教师名册记录 */
export interface TeacherRoster {
id: number;
/** 教工号(开放平台外部键) */
jgh: string;
/** 姓名 */
xm?: string;
/** 性别码 */
xbm?: string;
/** 性别 */
sex?: string;
/** 单位号(平台原始值) */
dwh?: string;
/** 解析出的学院ID */
collegeId?: number;
/** 学院名称 */
collegeName?: string;
/** 科室教研室编号 */
ksjybh?: string;
/** 状态:1启用 0停用 */
status?: number;
/** 最近同步时间 */
syncTime?: string;
}
/**
* 教师名册查询参数
*
* 说明:教师手机号由后端落盘但不出任何列表接口(spec §5.2),故此处没有该字段,
* 页面也不展示。
*/
export interface TeacherRosterParam {
page?: number;
limit?: number;
collegeId?: number;
jgh?: string;
xm?: string;
status?: number;
}
+18
View File
@@ -20,6 +20,24 @@ export async function login(data: LoginParam) {
return Promise.reject(new Error(res.data.message)); return Promise.reject(new Error(res.data.message));
} }
/**
* 学校统一身份认证登录(学号 + 统一身份认证密码)
*
* 与小程序端共用同一个后端接口,返回结构完全一致,
* 因此拿到 access_token 后前端流程与账号登录没有区别。
*/
export async function loginByGxmuSso(data: LoginParam) {
const res = await request.post<ApiResult<LoginResult>>('/sso/login', {
username: data.username,
password: data.password
});
if (res.data.code === 0) {
setToken(res.data.data?.access_token, data.remember);
return res.data.message;
}
return Promise.reject(new Error(res.data.message));
}
/** /**
* 获取验证码 * 获取验证码
*/ */
+2
View File
@@ -39,6 +39,8 @@ export interface FileRecordParam extends PageParam {
name?: string; name?: string;
path?: string; path?: string;
contentType?: string; contentType?: string;
// 媒体大类: image/video/audio/doc,空表示不限
mediaCategory?: string;
createNickname?: string; createNickname?: string;
count?: number; count?: number;
page?: number; page?: number;
@@ -47,7 +47,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -51,7 +51,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -47,7 +47,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -71,7 +71,7 @@
import { toTreeData, eachTreeData } from 'ele-admin-pro/es'; import { toTreeData, eachTreeData } from 'ele-admin-pro/es';
import type { EleProTable } from 'ele-admin-pro/es'; import type { EleProTable } from 'ele-admin-pro/es';
import { listOrganizations } from '@/api/system/organization'; import { listOrganizations } from '@/api/system/organization';
import { import type {
Organization, Organization,
OrganizationParam OrganizationParam
} from '@/api/system/organization/model'; } from '@/api/system/organization/model';
@@ -39,7 +39,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -41,7 +41,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -39,7 +39,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -41,7 +41,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -39,7 +39,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -40,7 +40,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -40,7 +40,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
+42
View File
@@ -0,0 +1,42 @@
/**
* AI 历史记录的类型(对应后端 `ai_chat_list.chat_type`)。
*
* 契约见 `docs/ai-history-contract.md`:类型由后端在生成成功时判定,前端只负责展示与筛选。
* 旧数据没有 `chat_type`,按聊天处理。
*/
export type AiChatType =
| 'chat'
| 'writing'
| 'image'
| 'creative'
| 'activity_outline';
/** 类型的中文名(徽标与后台表格共用) */
export const AI_CHAT_TYPE_LABELS: Record<AiChatType, string> = {
chat: '聊天',
writing: '写作',
image: '图片',
creative: '创意',
activity_outline: '活动策划'
};
/** 筛选下拉的可选项(不含「全部」,由使用方按需补充) */
export const AI_CHAT_TYPE_OPTIONS: { label: string; value: AiChatType }[] = [
{ label: '聊天', value: 'chat' },
{ label: '写作', value: 'writing' },
{ label: '图片', value: 'image' },
{ label: '创意', value: 'creative' },
{ label: '活动策划', value: 'activity_outline' }
];
/** 把后端返回值收敛到已知类型,缺失或未知一律按聊天处理 */
export function resolveAiChatType(value?: string | null): AiChatType {
const type = String(value || '').trim() as AiChatType;
return type in AI_CHAT_TYPE_LABELS ? type : 'chat';
}
/** 直接取中文名,用于模板与表格列 */
export function getAiChatTypeLabel(value?: string | null): string {
return AI_CHAT_TYPE_LABELS[resolveAiChatType(value)];
}
+154
View File
@@ -0,0 +1,154 @@
/**
* AI 结果的展示兜底。
*
* 与小程序端 `tuanwei-uniapp/utils/ai-result.js` 保持同一套语义 —— 同一批 AI 链路
* 在两端各有入口,兜底规则必须一致,否则同一个缺陷会在两端以不同面目出现
* (见 `.scratch/ai-assistant/issues/01`、`03`、`10`)。
*
* 背景:各 AI 链路在「拿不到已知字段」时会退回 `JSON.stringify(整包)`,
* 把后端原始报文(含 `conversationId` / `messageId` / `taskId` / `workflowRunId`
* 等内部标识符与上游错误栈)直接渲染给用户。同一个缺陷曾在多端、多页面里各写一份,
* 因此这里收敛为一处公共实现。
*
* 约定:
* - 解析不出内容时,一律给**可读文案**,绝不 stringify 整包;
* - 原始报文只应进日志(`console.error`),不进 UI;
* - 「内部标识符形状」的值(UUID、无空格长 token)不算可展示内容。
*/
/**
* 解析不出内容时的通用兜底文案。
*/
export const FALLBACK_TEXT = '接口已返回,但未解析到可展示的内容。';
/**
* 工作流失败时的统一提示。
*/
export const FAILED_TEXT = '生成失败,请稍后重试';
/**
* 取载荷里的工作流执行状态。
*
* 后端把 `status` 放在 data 的**顶层**(见 `buildManuscriptResult` /
* `buildActivityOutlineResult`);兼容 `data.status` 的嵌套写法
* (后端 `workflowStatus` 两处都看,前端不能只看一处)。
* 注释口径与 `tuanwei-uniapp/utils/ai-result.js` 保持一致。
*
* @param payload 接口返回
* @returns 小写、去空白的状态;取不到时为空串
*/
export function getPayloadStatus(payload: unknown): string {
if (!payload || typeof payload !== 'object') {
return '';
}
const record = payload as Record<string, any>;
const status =
record.status !== undefined && record.status !== null
? record.status
: record.data && record.data.status;
return String(status || '')
.trim()
.toLowerCase();
}
/**
* 工作流是否以失败收场。
*
* 只在拿到明确 `failed` 时为真;状态缺失视为未失败,避免误伤正常响应。
*
* @param payload 接口返回
*/
export function isFailedPayload(payload: unknown): boolean {
return getPayloadStatus(payload) === 'failed';
}
/**
* 判断一个字符串是否适合直接展示给用户。
*
* 失败报文里 `answer` 常被填成 `workflow_run_id`(UUID)这类内部标识符;
* 原样展示对用户毫无意义且构成泄露。
*
* @param value 待判断的值
*/
export function isDisplayableAnswer(value: unknown): boolean {
if (typeof value !== 'string') {
return false;
}
const text = value.trim();
if (!text) {
return false;
}
// 纯 UUID
if (
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)
) {
return false;
}
// 无空格的长 token(task_id / run_id 等)
if (!/\s/.test(text) && /^[0-9a-zA-Z_-]{32,}$/.test(text)) {
return false;
}
return true;
}
/**
* 在候选值里挑第一个可展示的字符串。
*
* @param values 候选值,按优先级排列
* @returns 挑中的值(已 trim);没有则空串
*/
export function pickDisplayable(...values: unknown[]): string {
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (isDisplayableAnswer(value)) {
return (value as string).trim();
}
}
return '';
}
/**
* 把原始报文记进日志(不进 UI)。
*
* @param scope 调用方标识,便于定位
* @param payload 原始载荷
*/
export function logRawPayload(scope: string, payload: unknown): void {
try {
console.error(
`[ai-result] ${scope} 未解析出可展示内容,原始载荷:`,
payload
);
} catch (error) {
// 日志失败不影响渲染
}
}
export interface ResolveDisplayTextOptions {
/** 原始载荷(用于失败判定与日志) */
payload?: unknown;
/** 日志标识 */
scope?: string;
/** 候选文案,按优先级排列(已剔除失败文案后的正文候选) */
candidates?: unknown[];
}
/**
* 展示兜底:优先可展示的候选值,其次失败文案 / 通用兜底文案。
*
* @returns 可读文案
*/
export function resolveDisplayText(
options: ResolveDisplayTextOptions = {}
): string {
const { payload, scope, candidates } = options;
if (isFailedPayload(payload)) {
return FAILED_TEXT;
}
const picked = pickDisplayable(...(candidates || []));
if (picked) {
return picked;
}
logRawPayload(scope || 'unknown', payload);
return FALLBACK_TEXT;
}
+250
View File
@@ -0,0 +1,250 @@
import { API_BASE_URL, TOKEN_HEADER_NAME } from '@/config/setting';
import { getSign, getToken } from '@/utils/token-util';
/**
* 后台的 HTTP 流式通道(SSE):WebSocket 连不上时的退路。
*
* 生产网关在 `/api` 前面就把 WebSocket 的 `Upgrade` 头丢了,握手永远到不了后端,
* 页面就卡在「正在建立 WebSocket 连接...」。后端因此给每条 AI 链路开了一份
* `/stream` 端点:请求体与原接口一模一样,响应是 `text/event-stream`,
* 每个 `data:` 行就是原来 WebSocket 上的一帧(同一段 JSON)。
*
* 所以页面只要把帧喂给原来的消息处理函数,落哪条通道都无感。
*/
const TENANT_ID = 10049;
export interface StreamOptions {
/** 相对 `/api` 的路径,如 `/ai/activityOutlineGen/stream` */
path: string;
data?: Record<string, unknown> | null;
/** 每帧回调,内容与 WebSocket 帧一致 */
onMessage: (raw: string) => void;
/** 中止生成(页面上的「停止」按钮) */
signal?: AbortSignal;
}
/** 发一条流式请求,把每个 SSE 帧原样交给 onMessage。 */
export async function streamAiRequest(options: StreamOptions): Promise<void> {
const payload: Record<string, unknown> = {
tenantId: TENANT_ID,
...(options.data || {})
};
// 与 axios 拦截器保持一致:后端校验 sign
payload.sign = getSign(payload);
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (token) {
headers[TOKEN_HEADER_NAME] = token;
}
const response = await fetch(`${API_BASE_URL}${options.path}`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: options.signal
});
if (!response.ok) {
throw new Error(`流式请求失败(HTTP ${response.status})`);
}
if (!response.body || typeof response.body.getReader !== 'function') {
throw new Error('当前浏览器不支持流式读取');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
const drain = () => {
let boundary = buffer.indexOf('\n\n');
while (boundary !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf('\n\n');
if (frame.startsWith(':')) {
// SSE 注释行(后端用它把响应头先顶出去)
continue;
}
const data = frame
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''))
.join('');
if (data) {
options.onMessage(data);
}
}
};
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
drain();
}
buffer += decoder.decode();
drain();
}
/** 对话(HTTP 流式) */
export function streamChatMessage(
data: Record<string, unknown>,
onMessage: (raw: string) => void,
signal?: AbortSignal
): Promise<void> {
return streamAiRequest({
path: '/ai/chat/message/stream',
data,
onMessage,
signal
});
}
/** 稿件生成(HTTP 流式) */
export function streamManuscriptGen(
data: Record<string, unknown>,
onMessage: (raw: string) => void,
signal?: AbortSignal
): Promise<void> {
return streamAiRequest({
path: '/ai/manuscriptGen/stream',
data,
onMessage,
signal
});
}
/** 活动策划大纲(HTTP 流式) */
export function streamActivityOutlineGen(
data: Record<string, unknown>,
onMessage: (raw: string) => void,
signal?: AbortSignal
): Promise<void> {
return streamAiRequest({
path: '/ai/activityOutlineGen/stream',
data,
onMessage,
signal
});
}
/** 创意助手(HTTP 流式) */
export function streamCreativeAssistant(
data: Record<string, unknown>,
onMessage: (raw: string) => void,
signal?: AbortSignal
): Promise<void> {
return streamAiRequest({
path: '/ai/creativeAssistant/stream',
data,
onMessage,
signal
});
}
/**
* 末帧 `result` 是普通接口的完整响应包(`{code, message, data}`)。
* 生成成功后后端会在包里带上 `chatId`(见 `docs/ai-history-contract.md`),
* 既可能在包上也可能在 `data` 里;这里统一把它合并进返回的 data,
* 页面只读 `result.chatId` 即可。
*/
function unwrapFinalData(finalResult: any) {
const data = finalResult?.data ?? null;
const chatId = Number(finalResult?.chatId);
if (
data &&
typeof data === 'object' &&
!Array.isArray(data) &&
Number.isFinite(chatId) &&
chatId > 0 &&
data.chatId == null
) {
data.chatId = chatId;
}
return data;
}
/**
* 创意助手(出图/出视频):跑完才有结果,中途一个字节都不推。
*
* 直接用普通 POST 的话,出图那一两分钟链路上完全空闲,会在 60s 左右被网关
* 掐成 504 Gateway Time-out(实测 58s 断开)。走流式后后端每 15s 发一帧心跳
* 续命,真正的结果放在末帧 `{type:'final', result}` 里,这里拆包后原样返回
* 普通接口的 data 字段,页面的渲染逻辑不用改。
*/
export async function runCreativeAssistantStream(
data: Record<string, unknown>,
signal?: AbortSignal
): Promise<Record<string, any> | null> {
let finalResult: any = null;
await streamCreativeAssistant(
data,
(raw) => {
try {
const frame = JSON.parse(raw);
if (frame && frame.type === 'final') {
finalResult = frame.result;
}
} catch {
// 业务帧解析失败就忽略:真正的失败原因由末帧的 message 带出来
}
},
signal
);
if (!finalResult) {
throw new Error('创意助手没有返回结果,请稍后重试');
}
if (Number(finalResult.code) !== 0) {
throw new Error(finalResult.message || '创意助手处理失败');
}
return unwrapFinalData(finalResult);
}
/**
* 稿件生成:正文逐段推帧,末帧 `{type:'final', result}` 带结构化结果。
*
* 自由创作(不选文物体裁)在后端会转「AI 创意助手」,整段**不推正文**,
* 只有末帧有内容;模板模式则是正文边推、末帧给标题等结构。
* 两种都按同一条路走:帧回调照常转发,末帧拆出来当普通接口的返回值。
*
* @param onMessage 业务帧回调(正文片段),可不传
* @returns 结构化结果(普通接口 data 字段);失败返回 null
*/
export async function runManuscriptGenStream(
data: Record<string, unknown>,
signal?: AbortSignal,
onMessage?: (raw: string) => void
): Promise<Record<string, any> | null> {
let finalResult: any = null;
await streamManuscriptGen(
data,
(raw) => {
try {
const frame = JSON.parse(raw);
if (frame && frame.type === 'final') {
finalResult = frame.result;
return;
}
} catch {
// 非 JSON 帧按业务帧处理
}
if (onMessage) {
onMessage(raw);
}
},
signal
);
if (!finalResult) {
return null;
}
if (Number(finalResult.code) !== 0) {
throw new Error(finalResult.message || '稿件生成失败');
}
return unwrapFinalData(finalResult);
}
+53
View File
@@ -0,0 +1,53 @@
import { API_BASE_URL } from '@/config/setting';
/**
* 后台与大屏端(Vue)的聊天 WebSocket 细节,集中在这里。
*
* 四个页面(创意助手、活动策划大纲、稿件生成、公文校验)此前各自抄了一份
* 「拼 URL + 连 + 断」,路径改一次要改四处;现在统一走这里。
*
* 路径是 **`/api/chat/{userId}`**,不是 `/chat/{userId}`:生产网关只放行 `/api` 前缀,
* 老路径的握手请求到不了后端(实测 404)。后端同时保留了根路径端点给老版本小程序兜底,
* 见 `tuanwei-java` 的 `WebSocketConfig`。
*/
/** 心跳间隔。nginx / 网关的空闲超时普遍在 60s 上下,25s 留够余量。 */
const HEARTBEAT_INTERVAL = 25000;
export function buildWebSocketUrl(userId: number | string): string {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/api/chat/${userId}`;
}
// 相对路径(同源反代):后台站点自己的域名下同样挂 /api/chat
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/api/chat/${userId}`;
}
/**
* 每 25s 发一个 `ping`,顶住反向代理与网关的空闲断开。
*
* 只发不收:页面把收到的每条消息当流式片段解析,服务端若回 pong 会被拼进正文。
* 服务端的 `@OnMessage` 收到文本直接丢弃。
*
* 连接关闭后定时器会自行结束(下次触发时发现 readyState 已是 CLOSED),
* 调用方不需要单独清理。
*/
export function attachHeartbeat(socket: WebSocket): () => void {
const timer = window.setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
try {
socket.send('ping');
} catch {
// 发送失败说明连接正在断,交给下一次 tick 或 onclose 处理
}
return;
}
if (socket.readyState === WebSocket.CLOSED) {
window.clearInterval(timer);
}
}, HEARTBEAT_INTERVAL);
return () => window.clearInterval(timer);
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 稿件正文的格式判定与转换(后台)。
*
* `cms_manuscript.content` 历史上混着两种来源:
* 1) wangEditor 保存的 HTML;
* 2) 从 AI 结果直接粘进后台的 markdown(`## 标题`、`**加粗**`、`- 列表`)。
* 后台编辑器只认 HTML:把 markdown 原样塞进 wangEditor,它会当成普通文字,
* 保存时再包一层 `<p>`,于是 `## 标题` 变成正文里的一行字——打开编辑即损坏。
*
* 所以打开编辑弹窗时先把非 HTML 的正文转成 HTML,保存时统一写 `contentFormat: 'html'`。
*/
import { getProcessor } from 'bytemd';
import type { BytemdPlugin } from 'bytemd';
const HTML_TAG_RE = /<\/?([a-z][a-z0-9]*)\b[^>]*>/i;
const MARKDOWN_LINE_RE =
/(^|\n)[ \t]{0,3}(#{1,6}[ \t]|[-*+][ \t]|\d+\.[ \t]|>[ \t])/;
const MARKDOWN_EMPHASIS_RE =
/(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(`[^`\n]+`)|(!\[[^\]]*\]\()/;
export type ContentFormat = 'html' | 'markdown' | 'text';
/** 后端 `contentFormat` 的规范值;认不出来返回空串,交给内容判断兜底。 */
export function normalizeFormat(format?: string | null): ContentFormat | '' {
const value = String(format ?? '')
.trim()
.toLowerCase();
if (value === 'html' || value === 'text') {
return value;
}
if (value === 'markdown' || value === 'md') {
return 'markdown';
}
return '';
}
export function looksLikeHtml(content?: string | null): boolean {
return HTML_TAG_RE.test(String(content ?? ''));
}
export function looksLikeMarkdown(content?: string | null): boolean {
const text = String(content ?? '');
if (!text.trim() || looksLikeHtml(text)) {
return false;
}
return MARKDOWN_LINE_RE.test(text) || MARKDOWN_EMPHASIS_RE.test(text);
}
/** 没有格式标记时的兜底判断(老接口、或写库前漏填的行)。 */
export function detectFormat(content?: string | null): ContentFormat {
const text = String(content ?? '');
if (!text.trim()) {
return 'html';
}
if (looksLikeHtml(text)) {
return 'html';
}
return looksLikeMarkdown(text) ? 'markdown' : 'text';
}
/**
* markdown → HTML,用的是后台已有的 bytemd(和 ByteMdViewer 同一套解析)。
*
* `plugins` 决定润色到什么程度:默认不挂 GFM,表格会原样漏成一堆 `|` 管道符、
* 删除线也会丢。要让「表格/删除线」和预览一致,调用方传 gfm 插件进来
* (generate 页的一键排版就是这么做的)。转换口径只留这一处,别再各写一套。
*/
export function markdownToHtml(
markdown: string,
plugins: BytemdPlugin[] = []
): string {
try {
// processSync 返回的是 vfile(`toString()` 即 HTML),不是裸字符串
const result = getProcessor({ plugins }).processSync(String(markdown ?? ''));
return String(result);
} catch (e) {
console.error('markdown 转换失败', e);
return plainTextToHtml(markdown);
}
}
function escapeHtml(text: string): string {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 纯文本 → HTML:空行分段,段内换行转 `<br>`,否则编辑器里会挤成一段。 */
export function plainTextToHtml(text: string): string {
return String(text ?? '')
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter((paragraph) => paragraph !== '')
.map(
(paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`
)
.join('');
}
+220
View File
@@ -0,0 +1,220 @@
import type { Component } from 'vue';
import {
CustomerServiceOutlined,
FileExcelOutlined,
FileMarkdownOutlined,
FilePdfOutlined,
FilePptOutlined,
FileTextOutlined,
FileUnknownOutlined,
FileWordOutlined,
VideoCameraOutlined
} from '@ant-design/icons-vue';
import type { FileRecord } from '@/api/system/file/model';
/**
* 媒体大类。
*
* 注意它和 `contentType`(MIME 字符串)不是一回事:MIME 里「文档」没有统一前缀
* (pdf/docx/zip 都是 application/…),所以大类由 MIME 前缀 + 后缀一起判定。
*/
export type MediaKind = 'image' | 'video' | 'audio' | 'doc';
/** 与后端 UploadRules 的白名单保持一致,改这里要同步改后端 */
export const MEDIA_ACCEPT = [
'image/*',
'.pdf',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.txt',
'.md',
'.csv',
'.mp3',
'.wav',
'.m4a',
'.aac',
'.mp4',
'.mov',
'.webm'
].join(',');
const DOC_SUFFIXES = [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'csv'
];
const VIDEO_SUFFIXES = ['mp4', 'mov', 'webm'];
const AUDIO_SUFFIXES = ['mp3', 'wav', 'm4a', 'aac'];
/** 图片大小上限(MB),其余类型上限(MB),受后端 multipart 100MB 硬顶约束 */
export const MEDIA_MAX_MB_IMAGE = 10;
export const MEDIA_MAX_MB_OTHER = 100;
/** 类型筛选项,value 为空表示不限 */
export const MEDIA_CATEGORY_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: '全部类型' },
{ value: 'image', label: '图片' },
{ value: 'doc', label: '文档' },
{ value: 'audio', label: '音频' },
{ value: 'video', label: '视频' }
];
export const MEDIA_KIND_LABEL: Record<MediaKind, string> = {
image: '图片',
video: '视频',
audio: '音频',
doc: '文档'
};
/** 取小写后缀,不带点 */
export function mediaSuffix(name?: string | null): string {
if (!name || name.lastIndexOf('.') < 0) {
return '';
}
return name.substring(name.lastIndexOf('.') + 1).toLowerCase();
}
/** 由 MIME + 文件名判定大类 */
export function mediaKindOf(
contentType?: string | null,
name?: string | null
): MediaKind {
const mime = (contentType || '').toLowerCase();
if (mime.startsWith('image/')) {
return 'image';
}
if (mime.startsWith('video/')) {
return 'video';
}
if (mime.startsWith('audio/')) {
return 'audio';
}
const suffix = mediaSuffix(name);
if (VIDEO_SUFFIXES.includes(suffix)) {
return 'video';
}
if (AUDIO_SUFFIXES.includes(suffix)) {
return 'audio';
}
return 'doc';
}
/** 由文件记录判定大类 */
export function mediaKindOfRecord(record?: FileRecord | null): MediaKind {
return mediaKindOf(record?.contentType, record?.path || record?.name);
}
/**
* 上传前校验,通过返回 null,不通过返回给用户看的错误文案。
* 与后端同规则:图片按 MIME 认,其余按后缀白名单认。
*/
export function validateMediaFile(file: File): string | null {
const mime = (file.type || '').toLowerCase();
const suffix = mediaSuffix(file.name);
const isImage = mime.startsWith('image/');
if (!isImage) {
const allowed =
DOC_SUFFIXES.includes(suffix) ||
VIDEO_SUFFIXES.includes(suffix) ||
AUDIO_SUFFIXES.includes(suffix);
if (!allowed) {
return '不支持的文件格式,仅支持图片、文档、音频、视频';
}
}
const maxMb = isImage ? MEDIA_MAX_MB_IMAGE : MEDIA_MAX_MB_OTHER;
if (file.size / 1024 / 1024 > maxMb) {
return `文件大小不能超过 ${maxMb}MB`;
}
return null;
}
/** 文件大小格式化 */
export function formatFileSize(length?: number | null): string {
const size = Number(length ?? 0);
if (!size || size < 0) {
return '-';
}
if (size < 1024) {
return size + 'B';
}
if (size < 1024 * 1024) {
return (size / 1024).toFixed(1) + 'KB';
}
if (size < 1024 * 1024 * 1024) {
return (size / 1024 / 1024).toFixed(1) + 'M';
}
return (size / 1024 / 1024 / 1024).toFixed(1) + 'G';
}
/** 卡片/表格里显示的类型图标 */
export function mediaIcon(kind: MediaKind, name?: string | null): Component {
if (kind === 'audio') {
return CustomerServiceOutlined;
}
if (kind === 'video') {
return VideoCameraOutlined;
}
switch (mediaSuffix(name)) {
case 'pdf':
return FilePdfOutlined;
case 'doc':
case 'docx':
return FileWordOutlined;
case 'xls':
case 'xlsx':
case 'csv':
return FileExcelOutlined;
case 'ppt':
case 'pptx':
return FilePptOutlined;
case 'md':
return FileMarkdownOutlined;
case 'txt':
return FileTextOutlined;
default:
return FileUnknownOutlined;
}
}
/** 图标色,按类型区分但都压在中低饱和,避免卡片花花绿绿 */
export function mediaColor(kind: MediaKind, name?: string | null): string {
if (kind === 'audio') {
return '#805ad5';
}
if (kind === 'video') {
return '#2b6cb0';
}
switch (mediaSuffix(name)) {
case 'pdf':
return '#e5484d';
case 'doc':
case 'docx':
return '#2b6cb0';
case 'xls':
case 'xlsx':
case 'csv':
return '#2f855a';
case 'ppt':
case 'pptx':
return '#c05621';
default:
return '#718096';
}
}
/** 非图片卡片右下角的扩展名徽标文本 */
export function mediaExtLabel(record?: FileRecord | null): string {
const suffix = mediaSuffix(record?.path || record?.name);
return suffix ? suffix.toUpperCase() : 'FILE';
}
+26 -3
View File
@@ -1,6 +1,14 @@
<!-- 搜索表单 --> <!-- 搜索表单 -->
<template> <template>
<a-space :size="10" style="flex-wrap: wrap"> <a-space :size="10" style="flex-wrap: wrap">
<a-select
v-model:value="where.chatType"
allow-clear
placeholder="请选择类型"
style="width: 180px"
:options="chatTypeOptions"
@change="search"
/>
<a-button type="primary" class="ele-btn-icon" @click="add"> <a-button type="primary" class="ele-btn-icon" @click="add">
<template #icon> <template #icon>
<PlusOutlined /> <PlusOutlined />
@@ -12,8 +20,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { PlusOutlined } from '@ant-design/icons-vue'; import { PlusOutlined } from '@ant-design/icons-vue';
import type { GradeParam } from '@/api/user/grade/model'; import type { AiChatListParam } from '@/api/ai/aiChatList/model';
import { watch } from 'vue'; import { AI_CHAT_TYPE_OPTIONS } from '@/utils/ai-chat-type';
import { reactive, watch } from 'vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -24,12 +33,26 @@
); );
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'search', where?: GradeParam): void; (e: 'search', where?: AiChatListParam): void;
(e: 'add'): void; (e: 'add'): void;
(e: 'remove'): void; (e: 'remove'): void;
(e: 'batchMove'): void; (e: 'batchMove'): void;
}>(); }>();
const chatTypeOptions = AI_CHAT_TYPE_OPTIONS;
const where = reactive<AiChatListParam>({
chatType: undefined
});
// 类型筛选:清空即回到全部
const search = () => {
emit('search', {
...where,
chatType: where.chatType || undefined
});
};
// 新增 // 新增
const add = () => { const add = () => {
emit('add'); emit('add');
+29
View File
@@ -24,6 +24,11 @@
<template v-if="column.key === 'image'"> <template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" /> <a-image :src="record.image" :width="50" />
</template> </template>
<template v-if="column.key === 'chatType'">
<a-tag :color="chatTypeColor(record.chatType)">
{{ getAiChatTypeLabel(record.chatType) }}
</a-tag>
</template>
<template v-if="column.key === 'status'"> <template v-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">显示</a-tag> <a-tag v-if="record.status === 1" color="green">显示</a-tag>
<a-tag v-if="record.status === 0" color="red">隐藏</a-tag> <a-tag v-if="record.status === 0" color="red">隐藏</a-tag>
@@ -64,6 +69,7 @@
import AiChatListEdit from './components/aiChatListEdit.vue'; import AiChatListEdit from './components/aiChatListEdit.vue';
import { pageAiChatList, removeAiChatList, removeBatchAiChatList } from '@/api/ai/aiChatList'; import { pageAiChatList, removeAiChatList, removeBatchAiChatList } from '@/api/ai/aiChatList';
import type { AiChatList, AiChatListParam } from '@/api/ai/aiChatList/model'; import type { AiChatList, AiChatListParam } from '@/api/ai/aiChatList/model';
import { getAiChatTypeLabel, resolveAiChatType } from '@/utils/ai-chat-type';
// 表格实例 // 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null); const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
@@ -79,6 +85,22 @@
// 加载状态 // 加载状态
const loading = ref(true); const loading = ref(true);
// 记录类型徽标的颜色,与助手页历史面板一致
const chatTypeColor = (value?: string) => {
switch (resolveAiChatType(value)) {
case 'writing':
return 'orange';
case 'image':
return 'purple';
case 'creative':
return 'magenta';
case 'activity_outline':
return 'green';
default:
return 'blue';
}
};
// 表格数据源 // 表格数据源
const datasource: DatasourceFunction = ({ const datasource: DatasourceFunction = ({
page, page,
@@ -107,6 +129,13 @@
align: 'center', align: 'center',
width: 90, width: 90,
}, },
{
title: '类型',
dataIndex: 'chatType',
key: 'chatType',
align: 'center',
width: 110
},
{ {
title: '', title: '',
dataIndex: 'conversationId', dataIndex: 'conversationId',
@@ -85,7 +85,7 @@ import { useUserStore } from "@/store/modules/user";
import type { Article } from "@/api/cms/article/model"; import type { Article } from "@/api/cms/article/model";
import { addArticle, updateArticle } from "@/api/cms/article"; import { addArticle, updateArticle } from "@/api/cms/article";
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting"; import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types"; import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { uploadFile } from "@/api/system/file"; import { uploadFile } from "@/api/system/file";
import TinymceEditor from "@/components/TinymceEditor/index.vue"; import TinymceEditor from "@/components/TinymceEditor/index.vue";
import { FormInstance, Rule, RuleObject } from "ant-design-vue/es/form"; import { FormInstance, Rule, RuleObject } from "ant-design-vue/es/form";
@@ -25,7 +25,7 @@
<a-form-item label="上级分类" name="parentId"> <a-form-item label="上级分类" name="parentId">
<a-tree-select <a-tree-select
allow-clear allow-clear
:tree-data="categoryList" :tree-data="parentOptions"
tree-default-expand-all tree-default-expand-all
placeholder="请选择上级分类" placeholder="请选择上级分类"
:value="form.parentId || undefined" :value="form.parentId || undefined"
@@ -84,13 +84,12 @@
v-model:value="form.comments" v-model:value="form.comments"
/> />
</a-form-item> </a-form-item>
</a-form> </a-form>
</ele-modal> </ele-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, watch } from 'vue'; import { ref, reactive, computed, watch } from 'vue';
import { message } from 'ant-design-vue/es'; import { message } from 'ant-design-vue/es';
import type { FormInstance, Rule } from 'ant-design-vue/es/form'; import type { FormInstance, Rule } from 'ant-design-vue/es/form';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -141,6 +140,26 @@
sortNumber: 100 sortNumber: 100
}); });
/*
* 上级分类候选:修改时要把自己及自己的整棵子树剔除,
* 否则可以把上级选成自己或自己的下级,形成环导致分类树无法渲染。
*/
const parentOptions = computed(() => {
const currentId = form.categoryId;
if (currentId == null) {
return props.categoryList ?? [];
}
const filterSelf = (list: ArticleCategory[]): ArticleCategory[] =>
(list ?? [])
.filter((d) => d.categoryId !== currentId)
.map((d) =>
d.children?.length
? { ...d, children: filterSelf(d.children) }
: { ...d }
);
return filterSelf(props.categoryList);
});
// 表单验证规则 // 表单验证规则
const rules = reactive<Record<string, Rule[]>>({ const rules = reactive<Record<string, Rule[]>>({
title: [ title: [
+16 -1
View File
@@ -195,8 +195,23 @@
/* 数据转为树形结构 */ /* 数据转为树形结构 */
const parseData = (data: ArticleCategory[]) => { const parseData = (data: ArticleCategory[]) => {
/*
* 防御性过滤:分类是自关联结构,如果库里存在「自己指向自己」的脏数据,
* toTreeData 会直接 throw,导致整个页面无法渲染(报 data error: {categoryId: x, parentId: x})。
* 这里先剔除这类记录,只影响异常的那一行,不拖垮整个列表。
*/
const validData = (data ?? []).filter((d) => d.categoryId !== d.parentId);
if (validData.length !== (data ?? []).length) {
const badIds = (data ?? [])
.filter((d) => d.categoryId === d.parentId)
.map((d) => d.categoryId)
.join('、');
message.error(
`分类 ${badIds} 的上级分类是它自己,已跳过显示,请在数据库中修正`
);
}
return toTreeData({ return toTreeData({
data: data.map((d) => { data: validData.map((d) => {
return { ...d, key: d.categoryId, value: d.categoryId }; return { ...d, key: d.categoryId, value: d.categoryId };
}), }),
idField: 'categoryId', idField: 'categoryId',
@@ -79,6 +79,12 @@
import { shallowRef, ref, reactive, watch, onBeforeUnmount } from 'vue'; import { shallowRef, ref, reactive, watch, onBeforeUnmount } from 'vue';
import { Form, message } from 'ant-design-vue'; import { Form, message } from 'ant-design-vue';
import { assignObject } from 'ele-admin-pro'; import { assignObject } from 'ele-admin-pro';
import {
detectFormat,
markdownToHtml,
normalizeFormat,
plainTextToHtml
} from '@/utils/manuscript-content';
import { addManuscript, updateManuscript } from '@/api/cms/manuscript'; import { addManuscript, updateManuscript } from '@/api/cms/manuscript';
import { Manuscript } from '@/api/cms/manuscript/model'; import { Manuscript } from '@/api/cms/manuscript/model';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
@@ -147,6 +153,7 @@
id: undefined, id: undefined,
title: undefined, title: undefined,
content: undefined, content: undefined,
contentFormat: 'html',
cover: undefined, cover: undefined,
deleted: undefined, deleted: undefined,
tenantId: undefined, tenantId: undefined,
@@ -161,6 +168,27 @@
emit('update:visible', value); emit('update:visible', value);
}; };
/**
* 入库格式 → 编辑器 HTML。
* 库里可能是 markdown 或纯文本(早期从 AI 结果粘进来的),wangEditor 只认 HTML,
* 原样塞进去会在保存时被当成普通文字包进 <p>,等于把正文改坏。
*/
const toEditorHtml = (data: Manuscript): string => {
const content = data.content || '';
const format = normalizeFormat(data.contentFormat) || detectFormat(content);
if (format === 'markdown') {
message.info('这篇稿件存的是 Markdown,已转换为富文本,请确认排版后保存');
return markdownToHtml(content);
}
if (format === 'text') {
message.info(
'这篇稿件存的是纯文本,已按段落转换为富文本,请确认排版后保存'
);
return plainTextToHtml(content);
}
return content;
};
const hasEditorContent = (html?: string) => { const hasEditorContent = (html?: string) => {
const text = String(html || '') const text = String(html || '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
@@ -257,7 +285,8 @@
loading.value = true; loading.value = true;
const formData = { const formData = {
...form, ...form,
content: contentHtml.value content: contentHtml.value,
contentFormat: 'html'
}; };
const saveOrUpdate = isUpdate.value ? updateManuscript : addManuscript; const saveOrUpdate = isUpdate.value ? updateManuscript : addManuscript;
const msg = await saveOrUpdate(formData); const msg = await saveOrUpdate(formData);
@@ -294,7 +323,7 @@
if (visible) { if (visible) {
if (props.data) { if (props.data) {
assignObject(form, props.data); assignObject(form, props.data);
contentHtml.value = props.data.content || ''; contentHtml.value = toEditorHtml(props.data);
coverList.value = props.data.cover coverList.value = props.data.cover
? [ ? [
{ {
@@ -47,7 +47,7 @@
:color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'" :color="item.status === 1 ? 'success' : item.status === 0 ? 'error' : 'default'"
style="margin-left: 8px" style="margin-left: 8px"
> >
{{ item.status === 1 ? '通过' : item.status === 0 ? '不通过' : '-' }} {{ item.status === 1 ? '通过' : item.status === 0 ? '驳回' : '-' }}
</a-tag> </a-tag>
</div> </div>
<div v-if="item.user?.realName" style="margin-bottom: 4px"> <div v-if="item.user?.realName" style="margin-bottom: 4px">
+33 -7
View File
@@ -29,7 +29,7 @@
<a-tag color="green">通过</a-tag> <a-tag color="green">通过</a-tag>
</template> </template>
<template v-else-if="getLastReview(record)?.status === 2"> <template v-else-if="getLastReview(record)?.status === 2">
<a-tag color="red">不通过</a-tag> <a-tag color="red">驳回</a-tag>
</template> </template>
<template v-else> <template v-else>
<a-tag>未审核</a-tag> <a-tag>未审核</a-tag>
@@ -58,7 +58,11 @@
</a-card> </a-card>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<ManuscriptEdit v-model:visible="showEdit" :data="current" @done="reload" /> <ManuscriptEdit
v-model:visible="showEdit"
:data="current"
@done="reload"
/>
<!-- 新增审核记录弹窗 --> <!-- 新增审核记录弹窗 -->
<ReviewRecordModal <ReviewRecordModal
v-model:visible="showReviewModal" v-model:visible="showReviewModal"
@@ -71,7 +75,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { createVNode, ref } from 'vue'; import { createVNode, ref } from 'vue';
import { message, Modal } from 'ant-design-vue'; import { message, Modal, Tag } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'; import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro'; import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro'; import { toDateString } from 'ele-admin-pro';
@@ -82,9 +86,14 @@
import Search from './components/search.vue'; import Search from './components/search.vue';
import ManuscriptEdit from './components/manuscriptEdit.vue'; import ManuscriptEdit from './components/manuscriptEdit.vue';
import ReviewRecordModal from './components/reviewRecordModal.vue'; import ReviewRecordModal from './components/reviewRecordModal.vue';
import { pageManuscript, removeManuscript, removeBatchManuscript } from '@/api/cms/manuscript'; import {
pageManuscript,
removeManuscript,
removeBatchManuscript
} from '@/api/cms/manuscript';
import type { Manuscript, ManuscriptParam } from '@/api/cms/manuscript/model'; import type { Manuscript, ManuscriptParam } from '@/api/cms/manuscript/model';
import type { ReviewList } from '@/api/gxmu/reviewList/model'; import type { ReviewList } from '@/api/gxmu/reviewList/model';
import { detectFormat, normalizeFormat } from '@/utils/manuscript-content';
// 表格实例 // 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null); const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
@@ -130,19 +139,36 @@
dataIndex: 'id', dataIndex: 'id',
key: 'id', key: 'id',
align: 'center', align: 'center',
width: 90, width: 90
}, },
{ {
title: '标题', title: '标题',
dataIndex: 'title', dataIndex: 'title',
key: 'title', key: 'title',
align: 'center', align: 'center'
}, },
{ {
title: '封面', title: '封面',
dataIndex: 'cover', dataIndex: 'cover',
key: 'cover', key: 'cover',
align: 'center'
},
{
title: '正文格式',
dataIndex: 'contentFormat',
key: 'contentFormat',
align: 'center', align: 'center',
width: 110,
customRender: ({ record }: { record: Manuscript }) => {
const format =
normalizeFormat(record.contentFormat) || detectFormat(record.content);
if (format === 'html') {
return createVNode(Tag, { color: 'default' }, () => 'HTML');
}
return createVNode(Tag, { color: 'orange' }, () =>
format === 'markdown' ? 'Markdown' : '纯文本'
);
}
}, },
{ {
title: '创建时间', title: '创建时间',
@@ -157,7 +183,7 @@
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
align: 'center', align: 'center'
}, },
{ {
title: '当前审核状态', title: '当前审核状态',
@@ -0,0 +1,93 @@
<!-- 音频 / 视频预览弹窗:素材库里非图片项的统一播放器 -->
<template>
<ele-modal
:width="kind === 'video' ? 880 : 480"
:footer="null"
:visible="visible"
:title="record?.name"
:destroy-on-close="true"
@update:visible="updateVisible"
>
<div v-if="visible && record" class="media-preview">
<ele-xg-player
v-if="kind === 'video'"
:key="'v-' + record.id"
:config="playerConfig"
/>
<div v-else class="media-preview-audio">
<customer-service-outlined class="media-preview-audio-icon" />
<div class="media-preview-audio-name ele-text-secondary">
{{ record.name }}
</div>
<audio
:key="'a-' + record.id"
:src="record.url"
controls
autoplay
class="media-preview-audio-player"
></audio>
</div>
</div>
</ele-modal>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { CustomerServiceOutlined } from '@ant-design/icons-vue';
import type { FileRecord } from '@/api/system/file/model';
import { mediaKindOfRecord } from '@/utils/media';
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 当前预览的文件
record?: FileRecord | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
}>();
const kind = computed(() => mediaKindOfRecord(props.record));
// xgplayer 配置,key 变化时重建实例,避免沿用上一条的播放状态
const playerConfig = computed(() => ({
id: 'cmsPhotoPreviewPlayer',
lang: 'zh-cn',
fluid: true,
url: props.record?.url,
playbackRate: [0.5, 1, 1.5, 2],
pip: true
}));
/* 更新 visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
</script>
<style lang="less" scoped>
.media-preview-audio {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0 8px;
}
.media-preview-audio-icon {
font-size: 48px;
color: #805ad5;
}
.media-preview-audio-name {
margin: 12px 0 20px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-preview-audio-player {
width: 100%;
}
</style>
+113 -113
View File
@@ -1,10 +1,10 @@
<!-- 角色编辑弹窗 --> <!-- 素材编辑弹窗:文件在列表页上传时已入库,这里只改元数据和分享代码 -->
<template> <template>
<ele-modal <ele-modal
:width="600" :width="600"
:visible="visible" :visible="visible"
:confirm-loading="loading" :confirm-loading="loading"
:title="isUpdate ? '编辑' : '上传文件'" title="编辑素材"
:body-style="{ paddingBottom: '8px' }" :body-style="{ paddingBottom: '8px' }"
okText="保存" okText="保存"
@update:visible="updateVisible" @update:visible="updateVisible"
@@ -19,27 +19,22 @@
styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' } styleResponsive ? { md: 19, sm: 19, xs: 24 } : { flex: '1' }
" "
> >
<a-form-item label="上传文件" name="fileName" v-if="!isUpdate"> <a-form-item label="文件">
<span <div class="media-file">
class="ele-text-success" <component
v-if="fileName" :is="fileIcon"
style="margin-right: 10px" class="media-file-icon"
> :style="{ color: fileColor }"
{{ fileName }} />
</span> <div class="media-file-info">
<a-upload <div class="media-file-name" :title="record?.name">
:show-upload-list="false" {{ record?.name }}
:accept="'video/*'" </div>
v-if="!fileName" <div class="ele-text-secondary media-file-sub">
:customRequest="onUpload" {{ kindLabel }} · {{ fileSize }}
> </div>
<a-button type="primary" class="ele-btn-icon"> </div>
<template #icon> </div>
<UploadOutlined />
</template>
<span>上传文件</span>
</a-button>
</a-upload>
</a-form-item> </a-form-item>
<a-form-item label="文件名称" name="name"> <a-form-item label="文件名称" name="name">
<a-input <a-input
@@ -53,17 +48,17 @@
<a-textarea <a-textarea
:rows="4" :rows="4"
:maxlength="200" :maxlength="200"
placeholder="图片描述" placeholder="素材描述"
v-model:value="form.comments" v-model:value="form.comments"
/> />
</a-form-item> </a-form-item>
<div style="margin-bottom: 22px"> <div style="margin-bottom: 22px">
<a-divider orientation="left">图片分享</a-divider> <a-divider orientation="left">{{ shareTitle }}</a-divider>
</div> </div>
<a-form-item label="图片URL链接"> <a-form-item label="素材链接">
<a-input v-model:value="share.url" @click="copyText(share.url)" /> <a-input v-model:value="share.url" @click="copyText(share.url)" />
</a-form-item> </a-form-item>
<a-form-item label="网页代码(HTML)"> <a-form-item label="网页代码(HTML)" v-if="share.html">
<a-input v-model:value="share.html" @click="copyText(share.html)" /> <a-input v-model:value="share.html" @click="copyText(share.html)" />
</a-form-item> </a-form-item>
<a-form-item label="Markdown"> <a-form-item label="Markdown">
@@ -72,38 +67,36 @@
@click="copyText(share.Markdown)" @click="copyText(share.Markdown)"
/> />
</a-form-item> </a-form-item>
<a-form-item label="论坛BBCode"> <a-form-item label="论坛BBCode" v-if="share.bbCode">
<a-input v-model:value="share.bbCode" @click="copyText(share.bbCode)" /> <a-input v-model:value="share.bbCode" @click="copyText(share.bbCode)" />
</a-form-item> </a-form-item>
<a-form-item label="下载地址">
<!-- <a-form-item label="封面图" name="images">--> <a-input
<!-- <ele-image-upload--> v-model:value="share.download"
<!-- v-model:value="images"--> @click="copyText(share.download)"
<!-- :limit="1"--> />
<!-- :drag="true"--> </a-form-item>
<!-- :item-style="{ width: '60px', height: '60px' }"-->
<!-- :accept="'image/*'"-->
<!-- :upload-handler="uploadHandler"-->
<!-- @upload="onUpload"-->
<!-- />-->
<!-- </a-form-item>-->
</a-form> </a-form>
</ele-modal> </ele-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { message } from 'ant-design-vue/es'; import { message } from 'ant-design-vue/es';
import type { FormInstance, Rule } from 'ant-design-vue/es/form'; import type { FormInstance, Rule } from 'ant-design-vue/es/form';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import useFormData from '@/utils/use-form-data'; import useFormData from '@/utils/use-form-data';
import type { FileRecord } from '@/api/system/file/model'; import type { FileRecord } from '@/api/system/file/model';
import { messageLoading } from 'ele-admin-pro'; import { updateFiles } from '@/api/system/file';
import { addFiles, updateFiles, uploadFile } from '@/api/system/file';
import { UploadOutlined } from '@ant-design/icons-vue';
import { RuleObject } from 'ant-design-vue/es/form';
import { copyText } from '@/utils/common'; import { copyText } from '@/utils/common';
import {
formatFileSize,
mediaColor,
mediaIcon,
mediaKindOfRecord,
MEDIA_KIND_LABEL
} from '@/utils/media';
// 是否开启响应式布局 // 是否开启响应式布局
const themeStore = useThemeStore(); const themeStore = useThemeStore();
@@ -121,20 +114,11 @@
data?: FileRecord | null; data?: FileRecord | null;
}>(); }>();
//
const formRef = ref<FormInstance | null>(null); const formRef = ref<FormInstance | null>(null);
const fileName = ref(''); // 当前编辑的原始记录,分享代码依赖它的 url,不随表单变化
// 是否是修改 const record = ref<FileRecord | null>(null);
const isUpdate = ref(false);
// 提交状态 // 提交状态
const loading = ref(false); const loading = ref(false);
// 图片资源分享
const share = ref({
url: '',
Markdown: '',
html: '',
bbCode: ''
});
// 表单数据 // 表单数据
const { form, resetFields, assignFields } = useFormData<FileRecord>({ const { form, resetFields, assignFields } = useFormData<FileRecord>({
@@ -143,22 +127,49 @@
comments: '' comments: ''
}); });
const kind = computed(() => mediaKindOfRecord(record.value));
const kindLabel = computed(() => MEDIA_KIND_LABEL[kind.value]);
const fileSize = computed(() => formatFileSize(record.value?.length));
const fileIcon = computed(() =>
mediaIcon(kind.value, record.value?.path || record.value?.name)
);
const fileColor = computed(() =>
mediaColor(kind.value, record.value?.path || record.value?.name)
);
// 文档给的是链接与下载地址,图片/音视频给的是嵌入代码
const shareTitle = computed(() =>
kind.value === 'doc' ? '素材链接' : '素材分享'
);
/**
* 分享代码按类型生成:图片沿用 img 系列,音视频给 video/audio 标签,
* 文档只给直链和下载地址(不提供 HTML 嵌入)。
*/
const share = computed(() => {
const data = record.value;
const url = data?.url || '';
const name = data?.name || '';
const result = {
url,
html: '',
Markdown: `[${name}](${url})`,
bbCode: `[url=${url}]${name}[/url]`,
download: data?.downloadUrl || ''
};
if (kind.value === 'image') {
result.html = `<a href="${url}"><img src="${url}" alt="${name}" border="0" /></a>`;
result.Markdown = `[![${name}](${url})](${url})`;
result.bbCode = `[url=${url}][img]${url}[/img][/url]`;
} else if (kind.value === 'video') {
result.html = `<video src="${url}" controls></video>`;
} else if (kind.value === 'audio') {
result.html = `<audio src="${url}" controls></audio>`;
}
return result;
});
// 表单验证规则 // 表单验证规则
const rules = reactive<Record<string, Rule[]>>({ const rules = reactive<Record<string, Rule[]>>({
fileName: [
{
required: true,
message: '请上传文件',
type: 'string',
trigger: 'blur',
validator: async (_rule: RuleObject) => {
if (!isUpdate.value && fileName.value.length == 0) {
return Promise.reject('请上传文件');
}
return Promise.resolve();
}
}
],
name: [ name: [
{ {
required: true, required: true,
@@ -169,39 +180,6 @@
] ]
}); });
// 上传文件
const onUpload = (item) => {
const { file } = item;
if (!file.type.startsWith('video')) {
message.error('文件格式不正确!');
return;
}
if (file.size / 1024 / 1024 > 100) {
message.error('大小不能超过 100MB');
return;
}
const hide = messageLoading({
content: '上传中..',
duration: 0,
mask: true
});
uploadFile(file)
.then((data) => {
hide();
fileName.value = String(data.name);
// images.value.push({
// uid: data.id,
// url: FILE_THUMBNAIL + data.path,
// status: 'done'
// });
message.success('上传成功');
})
.catch((e) => {
message.error(e.message);
hide();
});
};
/* 保存编辑 */ /* 保存编辑 */
const save = () => { const save = () => {
if (!formRef.value) { if (!formRef.value) {
@@ -211,8 +189,7 @@
.validate() .validate()
.then(() => { .then(() => {
loading.value = true; loading.value = true;
const saveOrUpdate = isUpdate.value ? updateFiles : addFiles; updateFiles(form)
saveOrUpdate(form)
.then((msg) => { .then((msg) => {
loading.value = false; loading.value = false;
message.success(msg); message.success(msg);
@@ -236,21 +213,44 @@
() => props.visible, () => props.visible,
(visible) => { (visible) => {
if (visible) { if (visible) {
record.value = props.data ?? null;
if (props.data) { if (props.data) {
assignFields(props.data); assignFields(props.data);
// 图片分享代码
share.value.url = `${props.data.url}`;
share.value.html = `<a href="${props.data.url}"><img src="${props.data.url}" alt="${props.data.name}" border="0" /></a>`;
share.value.Markdown = `[![${props.data.name}](${props.data.url})](${props.data.url})`;
share.value.bbCode = `[url=${props.data.url}][img]${props.data.url}[/img][/url]`;
isUpdate.value = true;
} else {
isUpdate.value = false;
} }
} else { } else {
record.value = null;
resetFields(); resetFields();
formRef.value?.clearValidate(); formRef.value?.clearValidate();
} }
} }
); );
</script> </script>
<style lang="less" scoped>
.media-file {
display: flex;
align-items: center;
min-width: 0;
}
.media-file-icon {
flex: none;
font-size: 32px;
}
.media-file-info {
flex: 1;
min-width: 0;
margin-left: 10px;
}
.media-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-file-sub {
font-size: 12px;
}
</style>
+131 -28
View File
@@ -1,9 +1,10 @@
<!-- 素材库 · 网格视图 -->
<template> <template>
<div class="page"> <div class="page">
<a-row :gutter="20"> <a-row :gutter="20">
<a-col <a-col
v-for="(item, index) in data" v-for="item in data"
:key="index" :key="item.id"
v-bind=" v-bind="
styleResponsive styleResponsive
? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 } ? { xl: 4, lg: 8, md: 12, sm: 12, xs: 24 }
@@ -12,10 +13,33 @@
> >
<a-card :bordered="false" hoverable style="margin-top: 16px"> <a-card :bordered="false" hoverable style="margin-top: 16px">
<template #cover> <template #cover>
<a-image :src="item.url" alt="" /> <!-- 统一 4:3 封面盒子:图片裁剪填充,其余类型放类型图标 -->
<div class="media-cover" @click="openPreview(item)">
<a-image
v-if="kindOf(item) === 'image'"
class="media-cover-image"
:src="item.thumbnail || item.url"
:preview="{ src: item.url }"
/>
<template v-else>
<component
:is="iconOf(item)"
class="media-cover-icon"
:style="{ color: colorOf(item) }"
/>
<span class="media-cover-ext">{{ extOf(item) }}</span>
<play-circle-filled
v-if="kindOf(item) === 'video'"
class="media-cover-play"
/>
</template>
</div>
</template> </template>
<a-card-meta :title="item.name" @click="openEdit(item)"> <a-card-meta :title="item.name" @click="openEdit(item)">
<template #description> <template #description>
<div class="media-meta">
{{ labelOf(item) }} · {{ sizeOf(item) }}
</div>
<div <div
class="project-list-desc" class="project-list-desc"
v-if="item.comments" v-if="item.comments"
@@ -41,58 +65,85 @@
</a-row> </a-row>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<PhotoEdit v-model:visible="showEdit" :data="current" @done="query" /> <PhotoEdit v-model:visible="showEdit" :data="current" @done="query" />
<!-- 音视频预览 -->
<MediaPreview v-model:visible="showPreview" :record="current" />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch } from 'vue'; import { ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { PlayCircleFilled } from '@ant-design/icons-vue';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
import { FileRecord, FileRecordParam } from '@/api/system/file/model'; import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
import PhotoEdit from './components/photo-edit.vue'; import PhotoEdit from './components/photo-edit.vue';
import MediaPreview from './components/media-preview.vue';
import { timeAgo } from 'ele-admin-pro'; import { timeAgo } from 'ele-admin-pro';
import {
formatFileSize,
mediaColor,
mediaExtLabel,
mediaIcon,
mediaKindOfRecord,
MEDIA_KIND_LABEL,
type MediaKind
} from '@/utils/media';
const props = defineProps<{ defineProps<{
// 搜索关键字 // 列表数据
searchText: string; data?: FileRecord[];
// 搜索条件 // 搜索条件
where?: FileRecordParam | null; where?: FileRecordParam | null;
// 修改回显的数据
data?: FileRecord | null;
}>(); }>();
// 当前编辑数据
const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'done'): void; (e: 'done'): void;
}>(); }>();
// 当前操作的数据(编辑 / 预览共用)
const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示音视频预览
const showPreview = ref(false);
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const kindOf = (item: FileRecord): MediaKind => mediaKindOfRecord(item);
const iconOf = (item: FileRecord) =>
mediaIcon(kindOf(item), item.path || item.name);
const colorOf = (item: FileRecord) =>
mediaColor(kindOf(item), item.path || item.name);
const extOf = (item: FileRecord) => mediaExtLabel(item);
const labelOf = (item: FileRecord) => MEDIA_KIND_LABEL[kindOf(item)];
const sizeOf = (item: FileRecord) => formatFileSize(item.length);
/* 打开编辑弹窗 */ /* 打开编辑弹窗 */
const openEdit = (row?: FileRecord) => { const openEdit = (row?: FileRecord) => {
current.value = row ?? null; current.value = row ?? null;
showEdit.value = true; showEdit.value = true;
}; };
// 是否开启响应式布局 /* 点击封面:图片由 a-image 自己预览,音视频开播放器,文档新窗口打开 */
const themeStore = useThemeStore(); const openPreview = (row: FileRecord) => {
const { styleResponsive } = storeToRefs(themeStore); const kind = kindOf(row);
if (kind === 'image') {
return;
}
if (kind === 'doc') {
window.open(row.url, '_blank');
return;
}
current.value = row;
showPreview.value = true;
};
/* 刷新列表 */
const query = () => { const query = () => {
emit('done'); emit('done');
}; };
watch(
() => props.data,
(visible) => {
if (visible) {
console.log(props.data);
} else {
}
}
);
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -102,6 +153,58 @@
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
.media-cover {
position: relative;
display: flex;
align-items: center;
justify-content: center;
// 所有类型共用 4:3,混排时卡片高度才对得齐
aspect-ratio: 4 / 3;
overflow: hidden;
background: #f5f6f8;
cursor: pointer;
:deep(.ant-image) {
width: 100%;
height: 100%;
}
:deep(.ant-image-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.media-cover-icon {
font-size: 40px;
}
.media-cover-ext {
position: absolute;
right: 8px;
bottom: 8px;
padding: 0 6px;
font-size: 12px;
line-height: 18px;
color: #718096;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
}
.media-cover-play {
position: absolute;
font-size: 44px;
color: rgba(255, 255, 255, 0.92);
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.35));
}
.media-meta {
margin-bottom: 2px;
font-size: 12px;
color: #8c8c8c;
}
.project-list-desc { .project-list-desc {
height: 44px; height: 44px;
line-height: 22px; line-height: 22px;
+37 -27
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="page"> <div class="page">
<a-page-header :ghost="false" title="图片库"> <a-page-header :ghost="false" title="素材库">
<div class="ele-text-secondary"> 上传、分享有价值的图片素材 </div> <div class="ele-text-secondary"> 上传、分享图片、文档与音视频素材 </div>
<template #footer> <template #footer>
<a-tabs v-model:activeKey="activeKey"> <a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="1" tab="网格模式" /> <a-tab-pane key="1" tab="网格模式" />
@@ -10,16 +10,29 @@
<div style="margin-bottom: 10px"> <div style="margin-bottom: 10px">
<a-upload <a-upload
:show-upload-list="false" :show-upload-list="false"
:accept="'image/*'" :accept="MEDIA_ACCEPT"
:customRequest="onUpload" :customRequest="onUpload"
> >
<a-button key="1" type="primary" class="ele-btn-icon"> <a-button key="1" type="primary" class="ele-btn-icon">
<template #icon> <template #icon>
<UploadOutlined /> <UploadOutlined />
</template> </template>
<span>上传图片</span> <span>上传素材</span>
</a-button> </a-button>
</a-upload> </a-upload>
<a-select
v-model:value="category"
style="width: 120px; margin-left: 10px"
@change="onCategoryChange"
>
<a-select-option
v-for="item in MEDIA_CATEGORY_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-input-search <a-input-search
allow-clear allow-clear
style="width: 360px; margin-left: 10px" style="width: 360px; margin-left: 10px"
@@ -68,15 +81,22 @@
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue'; import { UploadOutlined } from '@ant-design/icons-vue';
import { FileRecord, FileRecordParam } from '@/api/system/file/model'; import type { FileRecord, FileRecordParam } from '@/api/system/file/model';
import Image from './image.vue'; import Image from './image.vue';
import List from './list.vue'; import List from './list.vue';
import { messageLoading } from 'ele-admin-pro'; import { messageLoading } from 'ele-admin-pro';
import { pageFiles, uploadFile } from '@/api/system/file'; import { pageFiles, uploadFile } from '@/api/system/file';
import { message } from 'ant-design-vue/es'; import { message } from 'ant-design-vue/es';
import {
MEDIA_ACCEPT,
MEDIA_CATEGORY_OPTIONS,
validateMediaFile
} from '@/utils/media';
const type = ref('name'); const type = ref('name');
const searchText = ref(''); const searchText = ref('');
// 媒体大类筛选,空串表示全部
const category = ref('');
const data = ref<FileRecord[]>([]); const data = ref<FileRecord[]>([]);
// 当前选项卡 // 当前选项卡
const activeKey = ref('1'); const activeKey = ref('1');
@@ -94,7 +114,6 @@
/* 查询数据 */ /* 查询数据 */
const query = () => { const query = () => {
console.log('query()');
if (type.value == 'name') { if (type.value == 'name') {
where.name = searchText.value; where.name = searchText.value;
} }
@@ -103,8 +122,8 @@
} }
where.page = page.value; where.page = page.value;
where.limit = limit.value; where.limit = limit.value;
where.contentType = 'image'; // 空串会被当成一个真实的筛选值,必须置空
console.log(where); where.mediaCategory = category.value || undefined;
const hide = messageLoading('请求中..', 0); const hide = messageLoading('请求中..', 0);
pageFiles(where).then((res) => { pageFiles(where).then((res) => {
count.value = Number(res?.count); count.value = Number(res?.count);
@@ -113,17 +132,20 @@
}); });
}; };
/* 切换类型回到第一页 */
const onCategoryChange = () => {
page.value = 1;
query();
};
query(); query();
// 上传文件 // 上传文件
const onUpload = (item) => { const onUpload = (item) => {
const { file } = item; const { file } = item;
if (!file.type.startsWith('image')) { const error = validateMediaFile(file);
message.error('只能选择图片'); if (error) {
return; message.error(error);
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return; return;
} }
const hide = messageLoading({ const hide = messageLoading({
@@ -132,7 +154,7 @@
mask: true mask: true
}); });
uploadFile(file) uploadFile(file)
.then((data) => { .then(() => {
hide(); hide();
message.success('上传成功'); message.success('上传成功');
query(); query();
@@ -149,15 +171,3 @@
name: 'PhotoIndex' name: 'PhotoIndex'
}; };
</script> </script>
<style lang="less" scoped>
.project-list-desc {
height: 44px;
line-height: 22px;
margin-bottom: 20px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>
+133 -31
View File
@@ -1,3 +1,4 @@
<!-- 素材库 · 列表视图 -->
<template> <template>
<div class="page"> <div class="page">
<a-card :bordered="false"> <a-card :bordered="false">
@@ -8,21 +9,21 @@
:columns="columns" :columns="columns"
:datasource="datasource" :datasource="datasource"
v-model:selection="selection" v-model:selection="selection"
:scroll="{ x: 800 }" :scroll="{ x: 860 }"
cache-key="proCmsVideoTable" cache-key="proCmsPhotoTable"
> >
<template #toolbar> <template #toolbar>
<a-space> <a-space>
<a-upload <a-upload
:show-upload-list="false" :show-upload-list="false"
:accept="'image/*'" :accept="MEDIA_ACCEPT"
:customRequest="onUpload" :customRequest="onUpload"
> >
<a-button type="primary" class="ele-btn-icon"> <a-button type="primary" class="ele-btn-icon">
<template #icon> <template #icon>
<UploadOutlined /> <UploadOutlined />
</template> </template>
<span>上传图片</span> <span>上传素材</span>
</a-button> </a-button>
</a-upload> </a-upload>
<a-button <a-button
@@ -37,6 +38,19 @@
</template> </template>
<span>删除</span> <span>删除</span>
</a-button> </a-button>
<a-select
v-model:value="category"
style="width: 120px"
@change="reload()"
>
<a-select-option
v-for="item in MEDIA_CATEGORY_OPTIONS"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
<a-input-search <a-input-search
allow-clear allow-clear
v-model:value="searchText" v-model:value="searchText"
@@ -60,7 +74,23 @@
</template> </template>
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'path'"> <template v-if="column.key === 'path'">
<a-image :src="record.url" :width="120" /> <!-- 与网格视图同一套 4:3 缩略图规则 -->
<div class="media-thumb" @click="openPreview(record)">
<a-image
v-if="kindOf(record) === 'image'"
:src="record.thumbnail || record.url"
:preview="{ src: record.url }"
:width="120"
/>
<template v-else>
<component
:is="iconOf(record)"
class="media-thumb-icon"
:style="{ color: colorOf(record) }"
/>
<span class="media-thumb-ext">{{ extOf(record) }}</span>
</template>
</div>
</template> </template>
<template v-if="column.dataIndex === 'name'"> <template v-if="column.dataIndex === 'name'">
<span>{{ record.name }}</span> <span>{{ record.name }}</span>
@@ -78,7 +108,7 @@
<a-divider type="vertical" /> <a-divider type="vertical" />
<a-popconfirm <a-popconfirm
placement="topRight" placement="topRight"
title="确定要删除此评价吗?" title="确定要删除此素材吗?"
@confirm="remove(record)" @confirm="remove(record)"
> >
<a class="ele-text-danger">删除</a> <a class="ele-text-danger">删除</a>
@@ -90,6 +120,8 @@
</a-card> </a-card>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" /> <PhotoEdit v-model:visible="showEdit" :data="current" @done="reload" />
<!-- 音视频预览 -->
<MediaPreview v-model:visible="showPreview" :record="current" />
</div> </div>
</template> </template>
@@ -109,6 +141,7 @@
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
import { messageLoading, toDateString } from 'ele-admin-pro/es'; import { messageLoading, toDateString } from 'ele-admin-pro/es';
import PhotoEdit from './components/photo-edit.vue'; import PhotoEdit from './components/photo-edit.vue';
import MediaPreview from './components/media-preview.vue';
import { import {
pageFiles, pageFiles,
removeFile, removeFile,
@@ -119,17 +152,33 @@
FileRecord, FileRecord,
FileRecordParam FileRecordParam
} from '@/api/system/file/model/index'; } from '@/api/system/file/model/index';
import {
formatFileSize,
mediaColor,
mediaExtLabel,
mediaIcon,
mediaKindOfRecord,
MEDIA_ACCEPT,
MEDIA_CATEGORY_OPTIONS,
MEDIA_KIND_LABEL,
validateMediaFile,
type MediaKind
} from '@/utils/media';
// 表格实例 // 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null); const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据 // 表格选中数据
const selection = ref<FileRecord[]>([]); const selection = ref<FileRecord[]>([]);
// 当前编辑数据 // 当前操作的数据(编辑 / 预览共用)
const current = ref<FileRecord | null>(null); const current = ref<FileRecord | null>(null);
// 是否显示编辑弹窗 // 是否显示编辑弹窗
const showEdit = ref(false); const showEdit = ref(false);
// 是否显示音视频预览
const showPreview = ref(false);
const type = ref('name'); const type = ref('name');
const searchText = ref(''); const searchText = ref('');
// 媒体大类筛选,空串表示全部
const category = ref('');
// 表格列配置 // 表格列配置
const columns = ref<ColumnItem[]>([ const columns = ref<ColumnItem[]>([
{ {
@@ -141,11 +190,17 @@
customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0) customRender: ({ index }) => index + (tableRef.value?.tableIndex ?? 0)
}, },
{ {
title: '图片', title: '缩略图',
dataIndex: 'path', dataIndex: 'path',
width: 180, width: 140,
key: 'path' key: 'path'
}, },
{
title: '类型',
dataIndex: 'contentType',
width: 100,
customRender: ({ record }) => MEDIA_KIND_LABEL[kindOf(record)]
},
{ {
title: '文件名称', title: '文件名称',
dataIndex: 'name' dataIndex: 'name'
@@ -161,26 +216,16 @@
sorter: true, sorter: true,
showSorterTooltip: false, showSorterTooltip: false,
ellipsis: true, ellipsis: true,
customRender: ({ text }) => { customRender: ({ text }) => formatFileSize(text),
if (text < 1024) {
return text + 'B';
} else if (text < 1024 * 1024) {
return (text / 1024).toFixed(1) + 'KB';
} else if (text < 1024 * 1024 * 1024) {
return (text / 1024 / 1024).toFixed(1) + 'M';
} else {
return (text / 1024 / 1024 / 1024).toFixed(1) + 'G';
}
},
width: 120 width: 120
}, },
{ {
title: '发布者', title: '上传人',
width: 120, width: 120,
dataIndex: 'createNickname' dataIndex: 'createNickname'
}, },
{ {
title: '发布时间', title: '上传时间',
dataIndex: 'createTime', dataIndex: 'createTime',
sorter: true, sorter: true,
width: 180, width: 180,
@@ -196,6 +241,13 @@
} }
]); ]);
const kindOf = (record: FileRecord): MediaKind => mediaKindOfRecord(record);
const iconOf = (record: FileRecord) =>
mediaIcon(kindOf(record), record.path || record.name);
const colorOf = (record: FileRecord) =>
mediaColor(kindOf(record), record.path || record.name);
const extOf = (record: FileRecord) => mediaExtLabel(record);
// 表格数据源 // 表格数据源
const datasource: DatasourceFunction = ({ page, limit, where, orders }) => { const datasource: DatasourceFunction = ({ page, limit, where, orders }) => {
where = {}; where = {};
@@ -205,7 +257,8 @@
if (type.value == 'createNickname') { if (type.value == 'createNickname') {
where.createNickname = searchText.value; where.createNickname = searchText.value;
} }
where.contentType = 'image'; // 空串会被当成一个真实的筛选值,必须置空
where.mediaCategory = category.value || undefined;
return pageFiles({ ...where, ...orders, page, limit }); return pageFiles({ ...where, ...orders, page, limit });
}; };
@@ -221,6 +274,20 @@
showEdit.value = true; showEdit.value = true;
}; };
/* 点击缩略图:音视频开播放器,文档新窗口打开 */
const openPreview = (row: FileRecord) => {
const kind = kindOf(row);
if (kind === 'image') {
return;
}
if (kind === 'doc') {
window.open(row.url, '_blank');
return;
}
current.value = row;
showPreview.value = true;
};
/* 删除单个 */ /* 删除单个 */
const remove = (row: FileRecord) => { const remove = (row: FileRecord) => {
const hide = messageLoading('请求中..', 0); const hide = messageLoading('请求中..', 0);
@@ -266,12 +333,9 @@
// 上传文件 // 上传文件
const onUpload = (item) => { const onUpload = (item) => {
const { file } = item; const { file } = item;
if (!file.type.startsWith('image')) { const error = validateMediaFile(file);
message.error('只能选择图片'); if (error) {
return; message.error(error);
}
if (file.size / 1024 / 1024 > 10) {
message.error('大小不能超过 10MB');
return; return;
} }
const hide = messageLoading({ const hide = messageLoading({
@@ -280,7 +344,7 @@
mask: true mask: true
}); });
uploadFile(file) uploadFile(file)
.then((data) => { .then(() => {
hide(); hide();
message.success('上传成功'); message.success('上传成功');
reload(); reload();
@@ -294,6 +358,44 @@
<script lang="ts"> <script lang="ts">
export default { export default {
name: 'VideoIndex' name: 'PhotoList'
}; };
</script> </script>
<style lang="less" scoped>
.media-thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 90px;
overflow: hidden;
background: #f5f6f8;
border-radius: 4px;
cursor: pointer;
:deep(.ant-image),
:deep(.ant-image-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.media-thumb-icon {
font-size: 28px;
}
.media-thumb-ext {
position: absolute;
right: 4px;
bottom: 4px;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: #718096;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
}
</style>
+1
View File
@@ -99,6 +99,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="青马工程" module-name="青马工程"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="十佳青年岗位能手" module-name="十佳青年岗位能手"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="十佳团支部书记" module-name="十佳团支部书记"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+1
View File
@@ -114,6 +114,7 @@
module="gxmu_tygl_form" module="gxmu_tygl_form"
module-name="团员管理" module-name="团员管理"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+1
View File
@@ -84,6 +84,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="挑战杯" module-name="挑战杯"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="五四红旗团委" module-name="五四红旗团委"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="五四红旗团支部" module-name="五四红旗团支部"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
@@ -146,7 +146,7 @@
import ByteMdViewer from '@/components/ByteMdViewer/index.vue'; import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form'; import { reviewWxxzxProjectBookQuality } from '@/api/gxmu/wxxzx-form';
import { stopMessage } from '@/api/oa/chatgpt'; import { stopMessage } from '@/api/oa/chatgpt';
import { API_BASE_URL } from '@/config/setting'; import { attachHeartbeat, buildWebSocketUrl } from '@/utils/chat-socket';
import { useUserStore } from '@/store/modules/user'; import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common'; import { copyText } from '@/utils/common';
import type { WxxzxProjectQualityReviewResult } from '@/api/gxmu/wxxzx-form/model'; import type { WxxzxProjectQualityReviewResult } from '@/api/gxmu/wxxzx-form/model';
@@ -249,17 +249,6 @@
}); });
}; };
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const clearStreamResolver = () => { const clearStreamResolver = () => {
streamResolverRef.value = null; streamResolverRef.value = null;
}; };
@@ -402,6 +391,10 @@
const connectPromise = new Promise<void>((resolve, reject) => { const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false; let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId)); const socket = new WebSocket(buildWebSocketUrl(userId));
socket.onopen = () => {
// 握手成功后再挂心跳:连接没建起来时发出去只会失败
attachHeartbeat(socket);
};
cleanupPending = () => { cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) { if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null; wsConnectPromiseRef.value = null;
+1
View File
@@ -105,6 +105,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="未来学术之星" module-name="未来学术之星"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+1
View File
@@ -89,6 +89,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="优秀共青团干部" module-name="优秀共青团干部"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+1
View File
@@ -333,6 +333,7 @@
:module="REVIEW_MODULE" :module="REVIEW_MODULE"
module-name="优秀共青团员" module-name="优秀共青团员"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="reload"
/> />
<ReviewHistoryModal <ReviewHistoryModal
v-model:visible="showReviewHistory" v-model:visible="showReviewHistory"
+73 -273
View File
@@ -4,11 +4,10 @@
<div> <div>
<div class="hero-kicker">AI EVENT PLANNING OUTLINE</div> <div class="hero-kicker">AI EVENT PLANNING OUTLINE</div>
<h1>活动策划大纲生成</h1> <h1>活动策划大纲生成</h1>
<p> <p>填写活动主题、受众、时间和预算,AI 给出一份结构完整的策划大纲,可直接改、复制或导出。</p>
输入活动主题、受众、时间和资金等信息,系统将调用 </div>
`/ai/activityOutlineGen` <div class="hero-actions">
生成结构化活动策划大纲,适合方案初稿、汇报材料和执行拆解场景。 <a-button ghost @click="goHistory">历史记录</a-button>
</p>
</div> </div>
</div> </div>
@@ -91,13 +90,6 @@
</a-button> </a-button>
</div> </div>
<div class="hint-block">
<div class="hint-title">提交说明</div>
<div class="hint-text">
必填字段会直接作为 `inputs` 透传给
AI,后端固定触发“活动策划大纲生成”指令,并返回整理后的正文结果。
</div>
</div>
</a-card> </a-card>
</a-col> </a-col>
@@ -140,31 +132,19 @@
<a-empty <a-empty
v-if="!loading && !hasOutlineContent" v-if="!loading && !hasOutlineContent"
description="填写左侧信息后开始生成活动策划大纲" description="填写活动信息后开始生成"
/> />
<div v-else class="result-shell"> <div v-else class="result-shell">
<div v-if="loading" class="loading-panel"> <div v-if="loading" class="loading-panel">
<div class="loading-kicker">OUTLINE GENERATING</div> <div class="loading-title">正在生成活动策划大纲…</div>
<div class="loading-title">AI 正在整理活动策划结构</div>
<div class="loading-desc">
正在综合活动主题、受众、时间与经费信息,生成可直接二次编辑的策划大纲。
</div>
</div> </div>
<div v-if="showThinkingIndicator" class="outline-thinking"> <div v-if="showThinkingIndicator" class="outline-thinking">
<span class="thinking-dot" aria-hidden="true"></span> <span class="thinking-dot" aria-hidden="true"></span>
<span>AI 正在生成正文内容,请稍候...</span> <span>正在生成正文…</span>
</div> </div>
<a-alert
v-if="resultSummary"
class="result-alert"
type="info"
show-icon
:message="resultSummary"
/>
<ByteMdEditor <ByteMdEditor
v-if="hasOutlineContent" v-if="hasOutlineContent"
v-model:value="editableOutlineMarkdown" v-model:value="editableOutlineMarkdown"
@@ -192,24 +172,32 @@
import zh_Hans from 'bytemd/locales/zh_Hans.json'; import zh_Hans from 'bytemd/locales/zh_Hans.json';
import { getProcessor } from 'bytemd'; import { getProcessor } from 'bytemd';
import { computed, onBeforeUnmount, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { messageLoading } from 'ele-admin-pro'; import { messageLoading } from 'ele-admin-pro';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { asBlob } from 'html-docx-js-typescript'; import { asBlob } from 'html-docx-js-typescript';
import ByteMdEditor from '@/components/ByteMdEditor/index.vue'; import ByteMdEditor from '@/components/ByteMdEditor/index.vue';
import { activityOutlineGen } from '@/api/ai/activityOutlineGen'; import { streamActivityOutlineGen } from '@/utils/ai-stream';
import type { import type {
ActivityOutlineGenParams, ActivityOutlineGenParams,
ActivityOutlineGenResponse ActivityOutlineGenResponse
} from '@/api/ai/activityOutlineGen/model'; } from '@/api/ai/activityOutlineGen/model';
import { API_BASE_URL } from '@/config/setting';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common'; import { copyText } from '@/utils/common';
import { FAILED_TEXT } from '@/utils/ai-result';
type ExampleType = 'volunteer' | 'salon' | 'sports'; type ExampleType = 'volunteer' | 'salon' | 'sports';
const { push } = useRouter();
/** 活动策划记录统一在助手页查看,这里只带类型跳过去 */
const goHistory = () => {
push({ path: '/ai', query: { type: 'activity_outline' } });
};
/** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */
type StreamResolver = { type StreamResolver = {
resolve: () => void; resolve: () => void;
reject: (error: Error) => void;
}; };
interface FormState { interface FormState {
@@ -222,7 +210,6 @@
event_meme: string; event_meme: string;
} }
const userStore = useUserStore();
const plugins = ref([ const plugins = ref([
gfm({ gfm({
locale: zh_HansGfm locale: zh_HansGfm
@@ -234,17 +221,10 @@
const result = ref<ActivityOutlineGenResponse | null>(null); const result = ref<ActivityOutlineGenResponse | null>(null);
const outlineMarkdown = ref(''); const outlineMarkdown = ref('');
const editableOutlineMarkdown = ref(''); const editableOutlineMarkdown = ref('');
const resultSummary = ref('');
const activeConversationId = ref<string>();
const activeMessageId = ref<string>();
const webSocketRef = ref<WebSocket | null>(null);
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
const streamStarted = ref(false); const streamStarted = ref(false);
const streamResolverRef = ref<StreamResolver | null>(null); const streamResolverRef = ref<StreamResolver | null>(null);
const loginUser = computed(() => userStore.info ?? {});
const STREAM_END_TEXT = '__END__'; const STREAM_END_TEXT = '__END__';
const SOCKET_CONNECTED_TEXT = '连接成功';
const THINK_OPEN_TAG = '<think>'; const THINK_OPEN_TAG = '<think>';
const THINK_CLOSE_TAG = '</think>'; const THINK_CLOSE_TAG = '</think>';
@@ -364,15 +344,6 @@
}; };
}; };
const pickNonEmptyString = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === 'string' && value.length > 0) {
return value;
}
}
return '';
};
const pickRawString = (...values: unknown[]) => { const pickRawString = (...values: unknown[]) => {
for (const value of values) { for (const value of values) {
if (typeof value === 'string') { if (typeof value === 'string') {
@@ -382,54 +353,10 @@
return undefined; return undefined;
}; };
const stringifyUnknown = (value: unknown) => { // 原本这里还有 pickNonEmptyString 与 stringifyUnknown 两个局部兜底,已移除:
try { // stringifyUnknown 会把整包原始报文(含 conversationId / messageId)渲染进编辑器;
return JSON.stringify(value, null, 2); // pickNonEmptyString 则只判「非空字符串」,会在失败报文里把 UUID 形状的 answer
} catch (e) { // 当正文。现在统一走 @/utils/ai-result(见 issue 10)。
return String(value ?? '');
}
};
const parseOutline = (payload?: ActivityOutlineGenResponse | null) => {
if (!payload) {
return '';
}
const answer = pickNonEmptyString(
payload.answer,
payload.raw?.answer,
payload.raw?.data?.answer,
payload.raw?.output,
payload.raw?.result,
payload.raw?.content,
payload.raw?.message
);
if (answer) {
return answer;
}
return stringifyUnknown(payload.raw || payload);
};
const buildSummary = () => {
const parts = [] as string[];
if (activeConversationId.value) {
parts.push(`conversationId: ${activeConversationId.value}`);
}
if (activeMessageId.value) {
parts.push(`messageId: ${activeMessageId.value}`);
}
return parts.join(' | ');
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
};
const clearStreamResolver = () => { const clearStreamResolver = () => {
streamResolverRef.value = null; streamResolverRef.value = null;
@@ -440,65 +367,35 @@
clearStreamResolver(); clearStreamResolver();
}; };
const rejectStream = (error: Error) => {
streamResolverRef.value?.reject(error);
clearStreamResolver();
};
const createStreamWaiter = () => { const createStreamWaiter = () => {
streamStarted.value = false; streamStarted.value = false;
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve) => {
streamResolverRef.value = { resolve, reject }; streamResolverRef.value = { resolve };
}); });
}; };
const disconnectWebSocket = () => { const appendOutlineChunk = (chunk: string, reset?: boolean) => {
wsConnectPromiseRef.value = null;
const socket = webSocketRef.value;
webSocketRef.value = null;
if (
socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)
) {
socket.close();
}
};
const appendOutlineChunk = (
chunk: string,
conversationId?: string,
messageId?: string
) => {
if (!chunk) { if (!chunk) {
return; return;
} }
if (conversationId && conversationId !== '0') {
activeConversationId.value = conversationId;
}
if (messageId && messageId !== '0') {
activeMessageId.value = messageId;
}
if (!streamStarted.value) { if (!streamStarted.value) {
streamStarted.value = true; streamStarted.value = true;
outlineMarkdown.value = ''; outlineMarkdown.value = '';
} }
// reset=true 表示「丢弃已累积的片段,只用本条内容」。
// 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成
// 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。
if (reset) {
outlineMarkdown.value = chunk;
} else {
outlineMarkdown.value += chunk; outlineMarkdown.value += chunk;
}
syncEditableOutline(); syncEditableOutline();
resultSummary.value = buildSummary() || '正在通过 WebSocket 接收流式内容';
}; };
const finishStream = (conversationId?: string, messageId?: string) => { const finishStream = () => {
if (conversationId && conversationId !== '0') {
activeConversationId.value = conversationId;
}
if (messageId && messageId !== '0') {
activeMessageId.value = messageId;
}
resultSummary.value = buildSummary() || '已通过 WebSocket 接收生成结果';
loading.value = false; loading.value = false;
resolveStream(); resolveStream();
disconnectWebSocket();
}; };
const handleSocketMessage = (raw: string) => { const handleSocketMessage = (raw: string) => {
@@ -508,100 +405,25 @@
try { try {
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (data?.answer === STREAM_END_TEXT) { if (data?.answer === STREAM_END_TEXT) {
finishStream(data?.conversationId, data?.messageId); finishStream();
return; return;
} }
if (data?.loading === 'true' || data?.loading === true) { if (data?.loading === 'true' || data?.loading === true) {
resultSummary.value = 'AI 正在组织活动策划大纲结构...';
return; return;
} }
const chunk = pickRawString(data?.notice, data?.answer); const chunk = pickRawString(data?.notice, data?.answer);
if (typeof chunk === 'string') { if (typeof chunk === 'string') {
appendOutlineChunk(chunk, data?.conversationId, data?.messageId); appendOutlineChunk(chunk, data?.reset === 'true' || data?.reset === true);
} }
} catch (e) { } catch (e) {
appendOutlineChunk(raw); appendOutlineChunk(raw);
} }
}; };
const connectWebSocket = async () => {
const userId = loginUser.value.userId;
if (!userId) {
return Promise.reject(new Error('未获取到当前登录用户'));
}
const currentSocket = webSocketRef.value;
if (currentSocket?.readyState === WebSocket.OPEN) {
return;
}
if (
(currentSocket?.readyState === WebSocket.OPEN ||
currentSocket?.readyState === WebSocket.CONNECTING) &&
wsConnectPromiseRef.value
) {
return wsConnectPromiseRef.value;
}
let cleanupPending = () => undefined;
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
}
};
socket.onmessage = (event) => {
const raw = String(event.data || '');
if (raw === SOCKET_CONNECTED_TEXT) {
if (!settled) {
settled = true;
cleanupPending();
resolve();
}
return;
}
handleSocketMessage(raw);
};
socket.onclose = () => {
if (webSocketRef.value === socket) {
webSocketRef.value = null;
}
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('活动策划大纲连接已关闭'));
return;
}
if (loading.value) {
rejectStream(new Error('活动策划大纲连接已关闭'));
loading.value = false;
}
cleanupPending();
};
socket.onerror = () => {
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('活动策划大纲连接失败'));
return;
}
if (loading.value) {
rejectStream(new Error('活动策划大纲连接失败'));
loading.value = false;
}
};
webSocketRef.value = socket;
});
wsConnectPromiseRef.value = connectPromise;
return connectPromise;
};
const clearResult = () => { const clearResult = () => {
result.value = null; result.value = null;
outlineMarkdown.value = ''; outlineMarkdown.value = '';
editableOutlineMarkdown.value = ''; editableOutlineMarkdown.value = '';
resultSummary.value = '';
activeConversationId.value = undefined;
activeMessageId.value = undefined;
}; };
const resetForm = () => { const resetForm = () => {
@@ -622,33 +444,34 @@
} }
loading.value = true; loading.value = true;
clearResult(); clearResult();
resultSummary.value = '正在建立 WebSocket 连接...';
try { try {
await connectWebSocket(); // 一律走 HTTP 流式(/stream),不再先连 WebSocket:
resultSummary.value = '连接成功,已提交生成任务,等待流式返回...'; // 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的 60s 空闲
// 超时掐成 504 Gateway Time-out。/stream 每 15s 有后端心跳续命。
const payload = buildPayload();
const waitStream = createStreamWaiter(); const waitStream = createStreamWaiter();
const data = await activityOutlineGen(buildPayload()); await streamActivityOutlineGen(
result.value = data || null; payload as unknown as Record<string, unknown>,
if (!streamStarted.value && data && parseOutline(data)) { (raw) => handleSocketMessage(raw)
outlineMarkdown.value = parseOutline(data); );
syncEditableOutline(); // 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
resultSummary.value = buildSummary() || '接口直接返回生成结果';
loading.value = false;
resolveStream(); resolveStream();
disconnectWebSocket();
} else {
await waitStream; await waitStream;
} const text = visibleOutlineMarkdown.value.trim();
if (!visibleOutlineMarkdown.value) { if (!text) {
message.warning('接口已返回,但未解析到正文内容'); message.warning('这次没有生成出内容,请重试');
return; } else if (text === FAILED_TEXT) {
} message.error('生成失败,请稍后重试');
} else {
message.success('活动策划大纲生成完成'); message.success('活动策划大纲生成完成');
}
} catch (e: any) { } catch (e: any) {
disconnectWebSocket(); message.error(e.message || '生成失败');
} finally {
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
// 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。
clearStreamResolver(); clearStreamResolver();
loading.value = false; loading.value = false;
message.error(e.message || '生成失败');
} }
}; };
@@ -798,7 +621,6 @@
}; };
onBeforeUnmount(() => { onBeforeUnmount(() => {
disconnectWebSocket();
clearStreamResolver(); clearStreamResolver();
}); });
</script> </script>
@@ -855,6 +677,18 @@
color: rgba(255, 238, 238, 0.9); color: rgba(255, 238, 238, 0.9);
} }
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
flex: none;
}
.hero-actions :deep(.ant-btn) {
border-radius: 999px;
}
.quick-actions-panel { .quick-actions-panel {
margin-bottom: 14px; margin-bottom: 14px;
padding: 16px; padding: 16px;
@@ -888,27 +722,6 @@
margin-top: 8px; margin-top: 8px;
} }
.hint-block {
margin-top: 18px;
padding: 16px 18px;
border-radius: 16px;
background: #f6f9fe;
border: 1px solid #d9e6fb;
}
.hint-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #274f84;
}
.hint-text {
color: #5f6f84;
line-height: 1.8;
font-size: 13px;
}
.result-card :deep(.ant-card-head) { .result-card :deep(.ant-card-head) {
border-bottom: 1px solid #edf2f8; border-bottom: 1px solid #edf2f8;
} }
@@ -937,29 +750,12 @@
border: 1px solid #d9e8fb; border: 1px solid #d9e8fb;
} }
.loading-kicker {
margin-bottom: 10px;
font-size: 12px;
letter-spacing: 0.18em;
color: #4875b0;
}
.loading-title { .loading-title {
font-size: 22px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #183b68; color: #183b68;
} }
.loading-desc {
margin-top: 10px;
line-height: 1.8;
color: #607086;
}
.result-alert {
border-radius: 14px;
}
.outline-thinking { .outline-thinking {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1033,5 +829,9 @@
.hero-panel h1 { .hero-panel h1 {
font-size: 28px; font-size: 28px;
} }
.hero-actions {
justify-content: flex-start;
}
} }
</style> </style>
@@ -0,0 +1,120 @@
<template>
<div class="attachment-list">
<a-empty
v-if="!files.length"
:description="emptyText"
class="attachment-empty"
/>
<div
v-for="(file, index) in files"
:key="`${file.url}-${index}`"
class="attachment-item"
>
<a-image
v-if="isImageFile(file.url)"
:src="file.url"
:width="96"
:height="96"
class="attachment-image"
/>
<div class="attachment-body">
<a
:href="file.url"
target="_blank"
rel="noreferrer"
class="attachment-name"
:title="file.name"
>
{{ file.name }}
</a>
<span class="attachment-tip">点击可预览</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import {
isImageFile,
parseAttachments,
type AttachmentFile
} from '@/views/gxmu/utils/attachment';
const props = withDefaults(
defineProps<{
// 原始附件字段值
value?: unknown;
// 已解析好的文件列表(优先使用)
files?: AttachmentFile[];
emptyText?: string;
}>(),
{
value: undefined,
files: undefined,
emptyText: '暂无材料'
}
);
const files = computed<AttachmentFile[]>(() => {
if (props.files) {
return props.files;
}
return parseAttachments(props.value);
});
</script>
<script lang="ts">
export default {
name: 'GxmuAttachmentList'
};
</script>
<style lang="less" scoped>
.attachment-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.attachment-item {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 10px;
border: 1px solid #eef1f6;
border-radius: 8px;
background: #fbfcfe;
}
.attachment-image {
flex-shrink: 0;
overflow: hidden;
border-radius: 6px;
}
.attachment-body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.attachment-name {
overflow: hidden;
max-width: 100%;
color: #1677ff;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-tip {
color: #999;
font-size: 12px;
}
.attachment-empty {
margin: 12px 0;
}
</style>
+70 -6
View File
@@ -26,6 +26,21 @@
</p> </p>
<p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p> <p class="ant-upload-hint">将 Excel 文件拖到此处,或点击上传</p>
</a-upload-dragger> </a-upload-dragger>
<a-alert
v-if="failures.length"
type="warning"
show-icon
:message="`${successCount} 条导入成功,${failures.length} 条被跳过`"
>
<template #description>
<div class="import-failures">
<div v-for="failure in failures" :key="failure.row">
第 {{ failure.row }} 行:{{ failure.reason }}
</div>
</div>
<a-button size="small" @click="exportFailures">下载失败清单</a-button>
</template>
</a-alert>
</a-space> </a-space>
</a-spin> </a-spin>
</ele-modal> </ele-modal>
@@ -63,6 +78,8 @@
}>(); }>();
const loading = ref(false); const loading = ref(false);
const failures = ref<{ row: number; reason: string }[]>([]);
const successCount = ref(0);
const description = computed( const description = computed(
() => () =>
@@ -141,13 +158,17 @@
if (!rows.length) { if (!rows.length) {
throw new Error('导入文件内容为空'); throw new Error('导入文件内容为空');
} }
const data = rows const entries = rows
.map((row, index) => props.createPayload(row, index + 2)) .map((row, index) => ({
.filter((item) => rowNo: index + 2,
item: props.createPayload(row, index + 2)
}))
.filter(({ item }) =>
props.templateFields.some((field) => props.templateFields.some((field) =>
!!String(item[field.key] ?? '').trim() !!String(item[field.key] ?? '').trim()
) )
); );
const data = entries.map((entry) => entry.item);
if (!data.length) { if (!data.length) {
throw new Error('未识别到有效数据'); throw new Error('未识别到有效数据');
} }
@@ -155,10 +176,28 @@
if (errors.length) { if (errors.length) {
throw new Error(errors.slice(0, 5).join(';')); throw new Error(errors.slice(0, 5).join(';'));
} }
for (const item of data) { failures.value = [];
await props.saveItem(item); successCount.value = 0;
for (const entry of entries) {
try {
await props.saveItem(entry.item);
successCount.value += 1;
} catch (e: any) {
failures.value.push({
row: entry.rowNo,
reason: e?.message || '保存失败'
});
} }
message.success(`成功导入 ${data.length} 条${props.moduleName}数据`); }
if (failures.value.length) {
// 被跳过的是「同一申报项目已有进行中/已通过/被驳回」的记录,保留在清单里供人工处理
message.warning(
`成功 ${successCount.value} 条,跳过 ${failures.value.length} 条`
);
emit('done');
return false;
}
message.success(`成功导入 ${successCount.value} 条${props.moduleName}数据`);
updateVisible(false); updateVisible(false);
emit('done'); emit('done');
} catch (e: any) { } catch (e: any) {
@@ -169,7 +208,32 @@
return false; return false;
}; };
const exportFailures = () => {
if (!failures.value.length) {
return;
}
const sheet = utils.aoa_to_sheet([
['行号', '跳过原因'],
...failures.value.map((item) => [item.row, item.reason])
]);
sheet['!cols'] = [{ wch: 10 }, { wch: 64 }];
writeFile(
{
SheetNames: ['失败清单'],
Sheets: { 失败清单: sheet }
},
`${props.moduleName}导入失败清单.xlsx`
);
};
const updateVisible = (value: boolean) => { const updateVisible = (value: boolean) => {
emit('update:visible', value); emit('update:visible', value);
}; };
</script> </script>
<style lang="less" scoped>
.import-failures {
max-height: 180px;
overflow: auto;
margin-bottom: 8px;
}
</style>
@@ -33,7 +33,7 @@
:color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'" :color="item.status === 1 ? 'success' : item.status === 2 ? 'error' : 'default'"
style="margin-left: 8px" style="margin-left: 8px"
> >
{{ item.status === 1 ? '通过' : item.status === 2 ? '不通过' : '待审核' }} {{ item.status === 1 ? '通过' : item.status === 2 ? '驳回' : '待审核' }}
</a-tag> </a-tag>
</div> </div>
<div v-if="item.user?.realName" style="margin-bottom: 4px"> <div v-if="item.user?.realName" style="margin-bottom: 4px">
@@ -3,7 +3,7 @@
<a-tag color="green">通过</a-tag> <a-tag color="green">通过</a-tag>
</template> </template>
<template v-else-if="latest?.status === 2"> <template v-else-if="latest?.status === 2">
<a-tag color="red">不通过</a-tag> <a-tag color="red">驳回</a-tag>
</template> </template>
<template v-else> <template v-else>
<a-tag>待审核</a-tag> <a-tag>待审核</a-tag>
+102 -219
View File
@@ -8,6 +8,9 @@
支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像/排版结果。 支持文本指令与多类型附件联合驱动工作流,自动返回视频、脚本 Markdown 或图像/排版结果。
</p> </p>
</div> </div>
<div class="hero-actions">
<a-button ghost @click="goHistory">历史记录</a-button>
</div>
</div> </div>
<a-row :gutter="[24, 24]"> <a-row :gutter="[24, 24]">
@@ -30,40 +33,6 @@
placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。" placeholder="请输入用户的文本指令或需求描述,例如:根据活动照片和通知文档,生成一条公众号推文封面及 60 秒短视频脚本。"
/> />
</a-form-item> </a-form-item>
<a-form-item label="上传附件">
<a-upload
:multiple="true"
:show-upload-list="false"
:accept="acceptTypes"
:customRequest="handleUpload"
>
<a-button>
<template #icon>
<UploadOutlined />
</template>
添加附件
</a-button>
</a-upload>
<div class="upload-tip">
支持图片、视频、文档、音频。
</div>
<div v-if="uploadedFiles.length" class="file-list">
<div
v-for="(item, index) in uploadedFiles"
:key="`${item.url || item.path || item.name}-${index}`"
class="file-item"
>
<div class="file-main">
<div class="file-badge">{{ getFileTypeLabel(item) }}</div>
<div class="file-text">
<div class="file-name">{{ item.name || getFileName(item) }}</div>
<div class="file-url">{{ resolveFileUrl(item) || '-' }}</div>
</div>
</div>
<a class="file-remove" @click="removeUploadedFile(index)">移除</a>
</div>
</div>
</a-form-item>
</a-form> </a-form>
<div class="submit-bar"> <div class="submit-bar">
@@ -92,9 +61,16 @@
</a-space> </a-space>
</template> </template>
<!--
必须同时判 `!workflowResponse`:若只判 `!hasAnyResult`,
则「已执行但失败 / 未识别到输出」时 hasAnyResult 为假,
会落回这个空状态,把下方 result-body 里的 status-alert
(失败文案 / 兜底文案)整个盖住 —— 用户看到的仍是
「填写文本指令后执行工作流」,等于什么都没发生(见 issue 10)。
-->
<a-empty <a-empty
v-if="!loading && !hasAnyResult" v-if="!loading && !hasAnyResult && !workflowResponse"
description="填写文本指令并上传附件后执行工作流" description="填写文本指令后执行工作流"
/> />
<div v-else class="result-body"> <div v-else class="result-body">
@@ -199,11 +175,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- <div v-if="rawOutputsText" class="result-section">-->
<!-- <div class="section-title">原始输出</div>-->
<!-- <pre class="raw-output">{{ rawOutputsText }}</pre>-->
<!-- </div>-->
</div> </div>
</a-card> </a-card>
</a-col> </a-col>
@@ -219,22 +190,28 @@
import highlight from '@bytemd/plugin-highlight-ssr'; import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css'; import 'highlight.js/styles/default.css';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { messageLoading } from 'ele-admin-pro';
import ByteMdViewer from '@/components/ByteMdViewer/index.vue'; import ByteMdViewer from '@/components/ByteMdViewer/index.vue';
import { creativeAssistant, uploadAiFile } from '@/api/ai/creativeAssistant'; import { runCreativeAssistantStream } from '@/utils/ai-stream';
import type { FileRecord } from '@/api/system/file/model'; import type { FileRecord } from '@/api/system/file/model';
import type { import type {
CreativeAssistantOutputs, CreativeAssistantOutputs,
CreativeAssistantResponse CreativeAssistantResponse,
CreativeFileList
} from '@/api/ai/creativeAssistant/model'; } from '@/api/ai/creativeAssistant/model';
import { copyText } from '@/utils/common'; import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, FAILED_TEXT } from '@/utils/ai-result';
type ExampleType = 'video' | 'script' | 'image'; type ExampleType = 'video' | 'script' | 'image';
const acceptTypes = const { push } = useRouter();
'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md';
/** 创意助手记录统一在助手页查看,这里只带类型跳过去 */
const goHistory = () => {
push({ path: '/ai', query: { type: 'creative' } });
};
const plugins = ref([ const plugins = ref([
gfm({ gfm({
locale: zh_HansGfm locale: zh_HansGfm
@@ -244,23 +221,56 @@
const loading = ref(false); const loading = ref(false);
const content = ref(''); const content = ref('');
const uploadedFiles = ref<Partial<FileRecord>[]>([]);
const workflowResponse = ref<CreativeAssistantResponse | null>(null); const workflowResponse = ref<CreativeAssistantResponse | null>(null);
const workflowOutputs = ref<CreativeAssistantOutputs | null>(null); const workflowOutputs = ref<CreativeAssistantOutputs | null>(null);
const canSubmit = computed(() => { const canSubmit = computed(() => {
return !!content.value.trim() || uploadedFiles.value.length > 0; return !!content.value.trim();
}); });
const videoFiles = computed(() => workflowOutputs.value?.video_result || []); /**
* 文件列表兜底:上游可能给成单个对象或裸地址字符串,不是数组时当一项处理,
* 否则视频区直接空掉(见禅道 bug #1)。
*/
const asFileList = (
value?: CreativeFileList | null
): Partial<FileRecord>[] => {
const items = !value ? [] : Array.isArray(value) ? value : [value];
// 裸地址统一包成 { url },下游的 resolveFileUrl / getFileName 不必各自判类型。
return items.map((item) =>
typeof item === 'string' ? { url: item } : item
);
};
const videoFiles = computed(() =>
asFileList(workflowOutputs.value?.video_result)
);
const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || ''); const scriptMarkdown = computed(() => workflowOutputs.value?.Script_result || '');
const photoFiles = computed(() => workflowOutputs.value?.photo_files || []); const photoFiles = computed(() =>
asFileList(workflowOutputs.value?.photo_files)
);
const statusMessage = computed(() => { const statusMessage = computed(() => {
return ( // 失败态优先:后端失败时 outputs 为空,若不先判失败,用户既看不到正文
// 也看不到原因,只有一个 toast 一闪而过(见 issue 10)。
if (isFailedPayload(workflowResponse.value)) {
return FAILED_TEXT;
}
const text =
workflowOutputs.value?.video_message || workflowOutputs.value?.video_message ||
workflowOutputs.value?.photo_message || workflowOutputs.value?.photo_message ||
'' '';
); if (text) {
return text;
}
// 接口已返回但认不出约定的输出字段:给可读文案,不要静默什么都不显示。
if (workflowResponse.value && !hasAnyResult.value) {
return resolveDisplayText({
payload: workflowResponse.value,
scope: 'gxmu/creativeAssistant.statusMessage',
candidates: []
});
}
return '';
}); });
const hasAnyResult = computed(() => { const hasAnyResult = computed(() => {
return !!( return !!(
@@ -269,16 +279,9 @@
photoFiles.value.length photoFiles.value.length
); );
}); });
const rawOutputsText = computed(() => { // 原本这里有一个 rawOutputsText(JSON.stringify 整包),已移除:
if (!workflowOutputs.value) { // 它会把 taskId / workflowRunId 等内部标识符渲染给用户,且模板里的引用
return ''; // 早已被注释掉、留下一个「看似有原始输出视图」的死代码(见 issue 10)。
}
try {
return JSON.stringify(workflowOutputs.value, null, 2);
} catch (e) {
return '';
}
});
const resultTagText = computed(() => { const resultTagText = computed(() => {
if (loading.value) { if (loading.value) {
return '执行中'; return '执行中';
@@ -304,12 +307,6 @@
return 'default'; return 'default';
}); });
const loadingTitle = computed(() => { const loadingTitle = computed(() => {
if (uploadedFiles.value.length && content.value.trim()) {
return '正在分析指令并处理附件';
}
if (uploadedFiles.value.length) {
return '正在解析附件并生成结果';
}
return '正在根据文本指令生成内容'; return '正在根据文本指令生成内容';
}); });
const loadingDescription = computed(() => { const loadingDescription = computed(() => {
@@ -363,25 +360,6 @@
return name.split('.').pop() || ''; return name.split('.').pop() || '';
}; };
const DOCUMENT_FILE_EXTS = new Set([
'TXT',
'MD',
'MARKDOWN',
'PDF',
'HTML',
'XLSX',
'XLS',
'DOCX',
'CSV',
'EML',
'MSG',
'PPTX',
'PPT',
'XML',
'EPUB'
]);
const AUDIO_FILE_EXTS = new Set(['MP3', 'M4A', 'WAV', 'WEBM', 'AMR']);
const VIDEO_FILE_EXTS = new Set(['MP4', 'MOV', 'MPEG', 'MPGA']);
const isImageFile = (file?: Partial<FileRecord> | null) => { const isImageFile = (file?: Partial<FileRecord> | null) => {
const contentType = String(file?.contentType || '').toLowerCase(); const contentType = String(file?.contentType || '').toLowerCase();
@@ -407,26 +385,6 @@
return ['mp3', 'wav', 'aac', 'm4a', 'ogg'].includes(getFileExt(file)); return ['mp3', 'wav', 'aac', 'm4a', 'ogg'].includes(getFileExt(file));
}; };
const getUploadFileType = (
file?: Partial<FileRecord> | null
): 'image' | 'document' | 'audio' | 'video' => {
const contentType = String(file?.contentType || '').toLowerCase();
const ext = getFileExt(file).toUpperCase();
if (contentType.startsWith('image/')) {
return 'image';
}
if (AUDIO_FILE_EXTS.has(ext)) {
return 'audio';
}
if (VIDEO_FILE_EXTS.has(ext)) {
return 'video';
}
if (DOCUMENT_FILE_EXTS.has(ext)) {
return 'document';
}
return 'document';
};
const getFileTypeLabel = (file?: Partial<FileRecord> | null) => { const getFileTypeLabel = (file?: Partial<FileRecord> | null) => {
if (isImageFile(file)) { if (isImageFile(file)) {
return '图片'; return '图片';
@@ -447,73 +405,37 @@
const resetAll = () => { const resetAll = () => {
content.value = ''; content.value = '';
uploadedFiles.value = [];
clearResult(); clearResult();
}; };
const removeUploadedFile = (index: number) => {
uploadedFiles.value = uploadedFiles.value.filter((_, current) => current !== index);
};
const handleUpload = async (options: any) => {
const rawFile = options.file as File;
if (rawFile.size / 1024 / 1024 > 100) {
message.error('单个附件不能超过 100MB');
options.onError?.(new Error('文件过大'));
return;
}
const hide = messageLoading({
content: '上传中..',
duration: 0,
mask: true
});
try {
const result = await uploadAiFile(rawFile);
uploadedFiles.value = [
...uploadedFiles.value,
{
id: result.id,
name: result.name || rawFile.name,
path: result.path,
url: result.url,
downloadUrl: result.downloadUrl,
contentType: result.contentType
}
];
options.onSuccess?.(result);
message.success('附件上传成功');
} catch (e: any) {
message.error(e.message || '上传失败');
options.onError?.(e);
} finally {
hide();
}
};
/**
* 请求体。
*
* <p>附件已下线:上游出图工具只做文生图,开始节点的 file-list 变量没有任何节点消费
* (见 issue 16),继续传只是让用户白等上传。
*
* <p>不传 {@code mode}:这个页面是通用的创意助手入口,用户可能写视频、脚本或图片需求,
* 由用户的文字自己说明意图;只有聊天页「图片」模式这类**用户显式选了功能**的地方
* 才传 {@code mode=image} 让后端包装提示词(见 issue 19)。
*/
const buildRequestPayload = () => { const buildRequestPayload = () => {
const payload: any = { const value = content.value.trim();
user_content: content.value.trim(), return {
user_contentString: content.value.trim() user_content: value,
user_contentString: value
}; };
if (uploadedFiles.value.length) {
payload.user_files = uploadedFiles.value.map((item) => ({
transfer_method: 'local_file',
type: getUploadFileType(item),
upload_file_id: item.id,
url: ''
}));
}
return payload;
}; };
const runWorkflow = async () => { const runWorkflow = async () => {
if (!canSubmit.value) { if (!canSubmit.value) {
message.warning('请先输入文本指令或上传附件'); message.warning('请先输入文本指令');
return; return;
} }
loading.value = true; loading.value = true;
try { try {
const result = await creativeAssistant(buildRequestPayload()); // 出图中途零字节,普通 POST 会被网关按 60s 空闲超时掐成 504,改走流式
const result = await runCreativeAssistantStream(buildRequestPayload());
workflowResponse.value = result || null; workflowResponse.value = result || null;
workflowOutputs.value = normalizeOutputPayload(result || null); workflowOutputs.value = normalizeOutputPayload(result || null);
if ( if (
@@ -543,7 +465,7 @@
const fillExample = (type: ExampleType) => { const fillExample = (type: ExampleType) => {
if (type === 'video') { if (type === 'video') {
content.value = content.value =
'请根据我上传的活动照片和通知文档,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。'; '以「青年志愿者进社区」为主题,生成一条 45 秒校园活动宣传短视频,画面节奏明快,配一段适合配音的解说文案,并输出视频结果。';
return; return;
} }
if (type === 'script') { if (type === 'script') {
@@ -552,7 +474,7 @@
return; return;
} }
content.value = content.value =
'请根据上传的活动照片、LOGO 和文案素材,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。'; '以「毕业季」为主题,生成一套公众号头图与活动海报排版,风格青春、明亮,输出图片文件。';
}; };
</script> </script>
@@ -605,6 +527,18 @@
color: rgba(255, 239, 241, 0.88); color: rgba(255, 239, 241, 0.88);
} }
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
flex: none;
}
.hero-actions :deep(.ant-btn) {
border-radius: 999px;
}
.panel-card { .panel-card {
border-radius: 20px; border-radius: 20px;
box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08); box-shadow: 0 14px 34px rgba(87, 54, 30, 0.08);
@@ -631,38 +565,6 @@
gap: 8px; gap: 8px;
} }
.upload-tip {
margin-top: 10px;
color: #88715f;
font-size: 12px;
line-height: 1.7;
}
.file-list {
margin-top: 14px;
display: grid;
gap: 10px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: #faf5ef;
border: 1px solid #c7e8ff;
}
.file-main {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.file-badge { .file-badge {
flex-shrink: 0; flex-shrink: 0;
min-width: 48px; min-width: 48px;
@@ -675,35 +577,12 @@
font-weight: 600; font-weight: 600;
} }
.file-text {
flex: 1;
min-width: 0;
}
.file-name,
.file-url,
.result-name { .result-name {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.file-name {
color: #3b2c20;
font-weight: 600;
}
.file-url {
margin-top: 4px;
color: #8c7462;
font-size: 12px;
}
.file-remove {
flex-shrink: 0;
color: #ff4d4f;
}
.submit-bar { .submit-bar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -987,6 +866,10 @@
font-size: 28px; font-size: 28px;
} }
.hero-actions {
justify-content: flex-start;
}
.video-grid, .video-grid,
.photo-grid { .photo-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+370
View File
@@ -0,0 +1,370 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="同步为异步执行:提交后立即返回,下方可实时查看进度。学生全量约 9.8 万条,预计 1~2 分钟。"
/>
<a-space style="flex-wrap: wrap; margin-bottom: 16px">
<a-button
v-if="canOperate"
type="primary"
:disabled="running"
@click="doSync('all')"
>
全部同步
</a-button>
<a-button
v-if="canOperate"
:disabled="running"
@click="doSync('dept')"
>
同步部门
</a-button>
<a-button
v-if="canOperate"
:disabled="running"
@click="doSync('teacher')"
>
同步教师
</a-button>
<a-button
v-if="canOperate"
:disabled="running"
@click="doSync('class')"
>
同步班级
</a-button>
<a-button
v-if="canOperate"
:disabled="running"
@click="doSync('student')"
>
同步学生
</a-button>
<a-button :disabled="running" @click="reload">刷新记录</a-button>
</a-space>
<div v-if="sections.length" style="margin-bottom: 16px">
<a-space style="margin-bottom: 8px">
<a-spin v-if="running" size="small" />
<span>
{{ running ? '正在同步' : '最近批次' }}
<a-typography-text v-if="batchId" type="secondary">
(批次 {{ batchId }})
</a-typography-text>
</span>
</a-space>
<a-table
row-key="syncType"
size="small"
:columns="progressColumns"
:data-source="sections"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'syncType'">
{{ sectionLabel(record.syncType) }}
</template>
<template v-else-if="column.key === 'state'">
<a-tag :color="stateColor(record.state)">
{{ stateText(record.state) }}
</a-tag>
</template>
<template v-else-if="column.key === 'counts'">
<span v-if="record.state === 'pending'">-</span>
<span v-else>
成功 {{ record.successCount ?? 0 }} / 失败
{{ record.failCount ?? 0 }} / 停用
{{ record.skipCount ?? 0 }}
</span>
</template>
<template v-else-if="column.key === 'errorMsg'">
<a-typography-text type="danger">
{{ record.errorMsg || '-' }}
</a-typography-text>
</template>
</template>
</a-table>
</div>
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space>
<a-select
v-model:value="syncType"
allow-clear
style="width: 160px"
placeholder="同步类型"
:options="typeOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="recordStatusColor(record.status)">
{{ recordStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'triggerType'">
{{ record.triggerType === 'schedule' ? '定时' : '手动' }}
</template>
</template>
</ele-pro-table>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { hasPermission } from '@/utils/permission';
import {
pageSyncRecord,
syncAll,
syncClass,
syncDept,
syncStatus,
syncStudent,
syncTeacher
} from '@/api/gxmu/sync';
import type {
SyncRecord,
SyncSectionStatus
} from '@/api/gxmu/sync/model';
type SyncKind = 'all' | 'dept' | 'teacher' | 'class' | 'student';
const POLL_INTERVAL = 2000;
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const running = ref(false);
const batchId = ref<string>('');
const sections = ref<SyncSectionStatus[]>([]);
const syncType = ref<string | undefined>(undefined);
let timer: ReturnType<typeof setInterval> | undefined;
const canOperate = computed(() => hasPermission('gxmu:sync:manual'));
const typeOptions = [
{ label: '部门', value: 'dept' },
{ label: '教师', value: 'teacher' },
{ label: '班级', value: 'class' },
{ label: '学生', value: 'student' }
];
const progressColumns: ColumnItem[] = [
{ title: '同步段', dataIndex: 'syncType', key: 'syncType', width: 120 },
{ title: '状态', dataIndex: 'state', key: 'state', width: 110 },
{ title: '数量', key: 'counts', width: 260 },
{ title: '错误', dataIndex: 'errorMsg', key: 'errorMsg' }
];
const columns = ref<ColumnItem[]>([
{ title: '批次', dataIndex: 'batchId', key: 'batchId', width: 200 },
{ title: '类型', dataIndex: 'syncType', key: 'syncType', width: 100 },
{
title: '触发方式',
dataIndex: 'triggerType',
key: 'triggerType',
width: 110
},
{ title: '触发人', dataIndex: 'triggerUser', key: 'triggerUser', width: 140 },
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
width: 180,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '结束时间',
dataIndex: 'endTime',
key: 'endTime',
width: 180,
customRender: ({ text }) => (text ? toDateString(text) : '-')
},
{
title: '成功',
dataIndex: 'successCount',
key: 'successCount',
align: 'center',
width: 90
},
{
title: '失败',
dataIndex: 'failCount',
key: 'failCount',
align: 'center',
width: 90
},
{
title: '停用/跳过',
dataIndex: 'skipCount',
key: 'skipCount',
align: 'center',
width: 110
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 90
},
{ title: '错误摘要', dataIndex: 'errorMsg', key: 'errorMsg', width: 260 }
]);
const sectionLabel = (type: string) =>
typeOptions.find((item) => item.value === type)?.label || type;
const stateText = (state: string) =>
({ pending: '待执行', running: '进行中', success: '成功', fail: '失败' }[
state
] || state);
const stateColor = (state: string) =>
({ pending: 'default', running: 'processing', success: 'green', fail: 'red' }[
state
] || 'default');
const recordStatusText = (status: number) =>
({ 0: '成功', 1: '失败', 2: '进行中' }[status] ?? '未知');
const recordStatusColor = (status: number) =>
({ 0: 'green', 1: 'red', 2: 'processing' }[status] ?? 'default');
const datasource: DatasourceFunction = ({ page, limit }) => {
return pageSyncRecord({
page,
limit,
syncType: syncType.value
}).catch((e) => {
message.error(e.message);
return { list: [] as SyncRecord[], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const stopPolling = () => {
if (timer) {
clearInterval(timer);
timer = undefined;
}
};
const applyStatus = (status: {
running: boolean;
batchId?: string;
sections?: SyncSectionStatus[];
}) => {
running.value = status.running;
batchId.value = status.batchId || '';
sections.value = status.sections || [];
};
const poll = async () => {
try {
const status = await syncStatus();
const wasRunning = running.value;
applyStatus(status);
if (!status.running) {
stopPolling();
if (wasRunning) {
const failed = (status.sections || []).filter(
(item) => item.state === 'fail'
);
if (failed.length) {
message.warning(
`同步结束,${failed.length} 个段失败:${failed
.map((item) => `${sectionLabel(item.syncType)}(${item.errorMsg || '未知错误'})`)
.join(';')}`
);
} else {
const total = (status.sections || []).reduce(
(sum, item) => sum + (item.successCount || 0),
0
);
message.success(`同步完成,共写入 ${total} 条`);
}
reload();
}
}
} catch (e: any) {
stopPolling();
running.value = false;
message.error(e?.message || '获取同步进度失败');
}
};
const startPolling = () => {
stopPolling();
timer = setInterval(poll, POLL_INTERVAL);
};
const doSync = (kind: SyncKind) => {
const task =
kind === 'all'
? syncAll()
: kind === 'dept'
? syncDept()
: kind === 'teacher'
? syncTeacher()
: kind === 'class'
? syncClass()
: syncStudent();
task
.then((id) => {
// 提交成功即认为在跑,先占位再等第一次轮询回填
running.value = true;
batchId.value = id;
sections.value = [];
message.success('已提交同步任务');
startPolling();
poll();
})
.catch((e) => {
message.error(e.message || '提交同步任务失败');
});
};
onMounted(() => {
// 页面打开时可能已有定时任务或别处触发的同步在跑,接管进度显示
poll().then(() => {
if (running.value) {
startPolling();
}
});
});
onUnmounted(stopPolling);
</script>
<script lang="ts">
export default {
name: 'DataSync'
};
</script>
@@ -149,6 +149,19 @@
</div> </div>
</a-form-item> </a-form-item>
</template> </template>
<a-form-item
label="允许重复提交"
name="allowRepeat"
extra="关闭时,同一用户在同一申报项目下同时只能有一条待审核申报;开启后可配置最多同时几条"
>
<a-switch
:checked="form.allowRepeat === 1"
@change="(checked: boolean) => (form.allowRepeat = checked ? 1 : 0)"
/>
</a-form-item>
<a-form-item v-if="form.allowRepeat === 1" label="最多同时几条" name="maxPending">
<a-input-number :min="1" :max="20" class="ele-fluid" v-model:value="form.maxPending" />
</a-form-item>
<a-form-item label="开始时间" name="startTime"> <a-form-item label="开始时间" name="startTime">
<a-date-picker <a-date-picker
allow-clear allow-clear
@@ -182,6 +195,10 @@
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { FormInstance } from 'ant-design-vue/es/form'; import { FormInstance } from 'ant-design-vue/es/form';
import { DECLARE_MODULE_OPTIONS } from '../moduleOptions'; import { DECLARE_MODULE_OPTIONS } from '../moduleOptions';
import {
TZBCY_PROJECT_GROUP_OPTIONS,
TZBCY_PROJECT_TYPE_OPTIONS
} from '../../TZBCYForm/options';
import { import {
normalizeDeclareRecord, normalizeDeclareRecord,
stringifyDeclareMultiValueField stringifyDeclareMultiValueField
@@ -244,6 +261,8 @@
publicProjectGroup: [''], publicProjectGroup: [''],
startTime: undefined, startTime: undefined,
endTime: undefined, endTime: undefined,
allowRepeat: 0,
maxPending: 1,
userId: undefined, userId: undefined,
tenantId: undefined, tenantId: undefined,
createTime: undefined, createTime: undefined,
@@ -273,29 +292,68 @@
const { resetFields } = useForm(form, rules); const { resetFields } = useForm(form, rules);
const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form'); const isTzbcyModule = computed(() => form.module === 'gxmu_tzbcy_form');
/**
* 挑战杯模块的默认配置
* 与挑战杯申报表共用 TZBCYForm/options 中的同一份常量,避免两处维护产生漂移
*/
type ChallengeCupConfigKey =
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup';
const CHALLENGE_CUP_CONFIG_KEYS: ChallengeCupConfigKey[] = [
'formProjectType',
'formProjectGroup',
'publicProjectType',
'publicProjectGroup'
];
const toOptionValues = (options: readonly { value: string }[]) =>
options.map((item) => item.value);
const getChallengeCupDefaultConfig = (
key: ChallengeCupConfigKey
): string[] => {
switch (key) {
case 'formProjectType':
case 'publicProjectType':
return toOptionValues(TZBCY_PROJECT_TYPE_OPTIONS);
default:
return toOptionValues(TZBCY_PROJECT_GROUP_OPTIONS);
}
};
// 列表是否为空(没有任意一个有效值,空行不算)
const isBlankConfigList = (list?: string[]) =>
!(list || []).some((item) => !!item?.trim());
// 仅填充空配置,已有的配置(含用户手动编辑过的内容)保持不变
const fillBlankConfigWithDefault = () => {
CHALLENGE_CUP_CONFIG_KEYS.forEach((key) => {
if (isBlankConfigList(form[key])) {
form[key] = getChallengeCupDefaultConfig(key);
}
});
};
// 选中挑战杯模块时套用默认配置
const applyChallengeCupPreset = () => {
if (isTzbcyModule.value) {
fillBlankConfigWithDefault();
}
};
const normalizeEditableList = (list?: string[]) => { const normalizeEditableList = (list?: string[]) => {
const values = (list || []).map((item) => item?.trim() || '').filter(Boolean); const values = (list || []).map((item) => item?.trim() || '').filter(Boolean);
return values.length ? values : ['']; return values.length ? values : [''];
}; };
const addConfigRow = ( const addConfigRow = (key: ChallengeCupConfigKey) => {
key:
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup'
) => {
form[key].push(''); form[key].push('');
}; };
const removeConfigRow = ( const removeConfigRow = (key: ChallengeCupConfigKey, index: number) => {
key:
| 'formProjectType'
| 'formProjectGroup'
| 'publicProjectType'
| 'publicProjectGroup',
index: number
) => {
form[key].splice(index, 1); form[key].splice(index, 1);
if (!form[key].length) { if (!form[key].length) {
form[key].push(''); form[key].push('');
@@ -418,6 +476,8 @@
); );
yearValue.value = props.data.year ? String(props.data.year) : undefined; yearValue.value = props.data.year ? String(props.data.year) : undefined;
isUpdate.value = true; isUpdate.value = true;
// 历史记录若未配置过挑战杯配置,补上默认值;已配置的记录保持原样
applyChallengeCupPreset();
} else { } else {
Object.assign(form, createDefaultForm()); Object.assign(form, createDefaultForm());
isUpdate.value = false; isUpdate.value = false;
@@ -435,14 +495,15 @@
watch( watch(
() => form.module, () => form.module,
(value) => { (value) => {
if (value !== 'gxmu_tzbcy_form') { if (value === 'gxmu_tzbcy_form') {
// 选中挑战杯模块时预填项目申报表 / 公开展示信息表的默认配置
applyChallengeCupPreset();
return;
}
// 切到其他模块时不清空配置,避免用户手滑切换后已编辑的内容丢失
// (非挑战杯模块不会展示也不会提交这些字段)
form.projectType = []; form.projectType = [];
form.projectGroup = []; form.projectGroup = [];
form.formProjectType = [''];
form.formProjectGroup = [''];
form.publicProjectType = [''];
form.publicProjectGroup = [''];
}
} }
); );
</script> </script>
+2 -1
View File
@@ -63,7 +63,7 @@
</template> </template>
<template v-else-if="column.key === 'status'"> <template v-else-if="column.key === 'status'">
<a-tag v-if="record.status === 1" color="green">通过</a-tag> <a-tag v-if="record.status === 1" color="green">通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">不通过</a-tag> <a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
<a-tag v-else>待审核</a-tag> <a-tag v-else>待审核</a-tag>
</template> </template>
<template v-else-if="column.key === 'submitTime'"> <template v-else-if="column.key === 'submitTime'">
@@ -92,6 +92,7 @@
:module="detailCurrent?.module" :module="detailCurrent?.module"
:module-name="getDeclareModuleLabel(detailCurrent?.module)" :module-name="getDeclareModuleLabel(detailCurrent?.module)"
:pk="detailCurrent?.id" :pk="detailCurrent?.id"
@done="loadHistoryData"
/> />
<ReviewFlowModal <ReviewFlowModal
v-model:visible="showReviewFlow" v-model:visible="showReviewFlow"
+412 -281
View File
@@ -4,13 +4,11 @@
<div> <div>
<div class="hero-kicker">AI CONTENT STUDIO</div> <div class="hero-kicker">AI CONTENT STUDIO</div>
<h1>AI内容创作与宣传助手</h1> <h1>AI内容创作与宣传助手</h1>
<p> <p>写新闻稿、活动总结、公众号推文或通知公告,说清需求就行,AI 先给出一份初稿,可以接着改。</p>
面向团委宣传、活动报道、新闻稿和推文草稿的快速生成场景。输入创作条件后,通过
`/ai/manuscriptGen` 发起生成任务,并通过 WebSocket 实时接收返回内容,支持继续编辑与复制。
</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a-button type="primary" ghost @click="goChatPage">进入问答助手</a-button> <a-button type="primary" ghost @click="goChatPage">进入问答助手</a-button>
<a-button type="primary" ghost @click="goHistory">历史记录</a-button>
</div> </div>
</div> </div>
@@ -21,19 +19,13 @@
<div class="quick-actions"> <div class="quick-actions">
<a-button block @click="fillDemo('news')">活动新闻稿</a-button> <a-button block @click="fillDemo('news')">活动新闻稿</a-button>
<a-button block @click="fillDemo('wechat')">公众号推文</a-button> <a-button block @click="fillDemo('wechat')">公众号推文</a-button>
<a-button block @click="fillDemo('speech')">发言稿提纲</a-button> <a-button block @click="fillDemo('notice')">通知公告</a-button>
<a-button block @click="fillDemo('free')">自由创作示例</a-button>
</div> </div>
</div> </div>
<a-card :bordered="false" class="panel-card"> <a-card :bordered="false" class="panel-card">
<template #title>创作配置</template> <template #title>创作配置</template>
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item label="稿件标题">
<a-input
v-model:value="form.theme"
allow-clear
placeholder="例如:广西医科大学开展2026年春季志愿服务活动"
/>
</a-form-item>
<a-form-item label="稿件类型"> <a-form-item label="稿件类型">
<a-select <a-select
v-model:value="form.docType" v-model:value="form.docType"
@@ -41,6 +33,21 @@
placeholder="请选择稿件类型" placeholder="请选择稿件类型"
/> />
</a-form-item> </a-form-item>
<a-form-item v-if="!templateMode" label="想创作的内容">
<a-textarea
v-model:value="form.freeContent"
:auto-size="{ minRows: 6, maxRows: 12 }"
placeholder="例如:帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右"
/>
</a-form-item>
<template v-if="templateMode">
<a-form-item label="稿件标题">
<a-input
v-model:value="form.theme"
allow-clear
placeholder="例如:广西医科大学开展2026年春季志愿服务活动"
/>
</a-form-item>
<a-form-item label="活动类型"> <a-form-item label="活动类型">
<a-select <a-select
v-model:value="form.eventType" v-model:value="form.eventType"
@@ -99,9 +106,6 @@
上传附件 上传附件
</a-button> </a-button>
</a-upload> </a-upload>
<div class="upload-tip">
支持图片、文档等附件,上传后按 `files` 字段一并提交给接口。
</div>
<div v-if="uploadedFiles.length" class="file-list"> <div v-if="uploadedFiles.length" class="file-list">
<div <div
v-for="(item, index) in uploadedFiles" v-for="(item, index) in uploadedFiles"
@@ -119,6 +123,7 @@
</div> </div>
</div> </div>
</a-form-item> </a-form-item>
</template>
</a-form> </a-form>
<div class="submit-bar"> <div class="submit-bar">
@@ -132,14 +137,6 @@
生成内容 生成内容
</a-button> </a-button>
</div> </div>
<div class="hint-block">
<div class="hint-title">提交说明</div>
<div class="hint-text">
页面会先提交 `POST /ai/manuscriptGen`,再按 `/ai` 页面的方式通过 WebSocket
流式接收生成结果;如果后端仍然直接返回正文,页面也会自动兼容解析。
</div>
</div>
</a-card> </a-card>
</a-col> </a-col>
@@ -155,6 +152,13 @@
</template> </template>
<template #extra> <template #extra>
<a-space> <a-space>
<a-button
type="primary"
:disabled="loading || !markdownContent"
@click="formatRichText"
>
一键排版
</a-button>
<a-button :disabled="loading || !markdownContent" @click="exportWord">导出 Word</a-button> <a-button :disabled="loading || !markdownContent" @click="exportWord">导出 Word</a-button>
<a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button> <a-button :disabled="!markdownContent" @click="copyGenerated">复制正文</a-button>
<a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button> <a-button :disabled="!generatedTitle" @click="copyGeneratedTitle">复制标题</a-button>
@@ -162,7 +166,7 @@
</a-space> </a-space>
</template> </template>
<a-empty v-if="!markdownContent && !loading" description="填写左侧创作条件后开始生成" /> <a-empty v-if="!markdownContent && !loading" description="填写创作条件后开始生成" />
<template v-else> <template v-else>
<a-form layout="vertical"> <a-form layout="vertical">
@@ -174,6 +178,12 @@
/> />
</a-form-item> </a-form-item>
<a-form-item label="正文内容"> <a-form-item label="正文内容">
<a-tabs
v-model:activeKey="contentTab"
size="small"
class="content-tabs"
>
<a-tab-pane key="markdown" tab="Markdown 预览">
<div class="markdown-shell"> <div class="markdown-shell">
<div v-if="loading" class="markdown-streaming"> <div v-if="loading" class="markdown-streaming">
{{ markdownContent || 'AI 正在生成内容...' }} {{ markdownContent || 'AI 正在生成内容...' }}
@@ -185,13 +195,40 @@
></div> ></div>
<div v-else class="markdown-empty">生成结果将显示在这里</div> <div v-else class="markdown-empty">生成结果将显示在这里</div>
</div> </div>
</a-tab-pane>
<a-tab-pane key="rich" tab="富文本排版">
<div class="rich-shell">
<div class="rich-bar">
<span class="rich-bar-tip">
一键排版按原文的标题层级、段落、列表、表格还原,可直接在编辑器里继续改
</span>
<a-button
size="small"
type="primary"
:disabled="saveDisabled"
@click="saveAsManuscript"
>
{{ savedUpToDate ? '已保存为稿件' : '保存为稿件' }}
</a-button>
</div>
<Toolbar
class="rich-toolbar"
:editor="editorRef"
:defaultConfig="toolbarConfig"
mode="default"
/>
<Editor
v-model="richContentHtml"
class="rich-editor"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleEditorCreated"
/>
</div>
</a-tab-pane>
</a-tabs>
</a-form-item> </a-form-item>
</a-form> </a-form>
<!-- <div v-if="responseSummary" class="response-meta">-->
<!-- <div class="meta-label">接口摘要</div>-->
<!-- <div class="meta-value">{{ responseSummary }}</div>-->
<!-- </div>-->
</template> </template>
</a-card> </a-card>
</a-col> </a-col>
@@ -206,27 +243,38 @@
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json'; import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import highlight from '@bytemd/plugin-highlight-ssr'; import highlight from '@bytemd/plugin-highlight-ssr';
import 'highlight.js/styles/default.css'; import 'highlight.js/styles/default.css';
import { computed, onBeforeUnmount, reactive, ref } from 'vue'; import { computed, onBeforeUnmount, reactive, ref, shallowRef } from 'vue';
import { UploadOutlined } from '@ant-design/icons-vue'; import { UploadOutlined } from '@ant-design/icons-vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue'; import { Modal, message } from 'ant-design-vue';
import { messageLoading } from 'ele-admin-pro'; import { messageLoading } from 'ele-admin-pro';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { asBlob } from 'html-docx-js-typescript'; import { asBlob } from 'html-docx-js-typescript';
import { getProcessor } from 'bytemd'; import { getProcessor } from 'bytemd';
import { manuscriptGen } from '@/api/ai/manuscriptGen'; import { runManuscriptGenStream } from '@/utils/ai-stream';
import type { import type {
ManuscriptGenParams, ManuscriptGenParams,
ManuscriptGenResult ManuscriptGenResult
} from '@/api/ai/manuscriptGen/model'; } from '@/api/ai/manuscriptGen/model';
import { uploadFile } from '@/api/system/file'; import { uploadFile } from '@/api/system/file';
import type { FileRecord } from '@/api/system/file/model'; import type { FileRecord } from '@/api/system/file/model';
import { API_BASE_URL } from '@/config/setting';
import { useUserStore } from '@/store/modules/user';
import { copyText } from '@/utils/common'; import { copyText } from '@/utils/common';
import { resolveDisplayText, isFailedPayload, pickDisplayable, FAILED_TEXT } from '@/utils/ai-result';
import { markdownToHtml } from '@/utils/manuscript-content';
import { addManuscript } from '@/api/cms/manuscript';
import type { Manuscript } from '@/api/cms/manuscript/model';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type {
IDomEditor,
IEditorConfig,
IToolbarConfig
} from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
interface FormState { interface FormState {
/** 空串 = 自由创作(不套模板);非空 = 文稿模板,走「内容创作与宣传助手」 */
docType: string; docType: string;
freeContent: string;
eventType: string; eventType: string;
theme: string; theme: string;
time: string; time: string;
@@ -239,17 +287,15 @@
interface ParsedResult { interface ParsedResult {
title: string; title: string;
content: string; content: string;
summary: string;
} }
type DemoType = 'news' | 'wechat' | 'speech'; type DemoType = 'news' | 'wechat' | 'notice' | 'free';
/** 等「流结束」的挂起点:只由 __END__ 帧或请求收尾来 resolve */
type StreamResolver = { type StreamResolver = {
resolve: () => void; resolve: () => void;
reject: (error: Error) => void;
}; };
const { push } = useRouter(); const { push } = useRouter();
const userStore = useUserStore();
const plugins = ref([ const plugins = ref([
gfm({ gfm({
locale: zh_HansGfm locale: zh_HansGfm
@@ -257,25 +303,63 @@
highlight() highlight()
]); ]);
const loading = ref(false); const loading = ref(false);
const wsConnected = ref(false); /** 正文区域展示方式:默认看 Markdown 预览,一键排版后切到富文本 */
const contentTab = ref<'markdown' | 'rich'>('markdown');
/** 富文本编辑器里的 HTML(一键排版灌入,人工可继续改) */
const richContentHtml = ref('');
const savingManuscript = ref(false);
/** 上次保存时的正文文本快照:内容没变就不再让点「保存为稿件」,避免存出重复草稿 */
const savedSnapshot = ref('');
const editorRef = shallowRef<IDomEditor>();
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['group-video']
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: '点上方「一键排版」,把生成结果按原文排版放进这里',
MENU_CONF: {
uploadImage: {
allowedFileTypes: ['image/*'],
maxFileSize: 2 * 1024 * 1024,
base64LimitSize: 0,
async customUpload(file: File, insertFn: (url: string) => void) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片');
return;
}
try {
const result = await uploadFile(file);
insertFn(result.url || '');
} catch (e: any) {
message.error(e.message || '图片上传失败');
}
}
}
}
};
const handleEditorCreated = (editor: IDomEditor) => {
editorRef.value = editor;
};
const generatedTitle = ref(''); const generatedTitle = ref('');
const generatedContent = ref(''); const generatedContent = ref('');
const responseSummary = ref('');
const uploadedFiles = ref<Partial<FileRecord>[]>([]); const uploadedFiles = ref<Partial<FileRecord>[]>([]);
const activeConversationId = ref<string>();
const webSocketRef = ref<WebSocket | null>(null);
const wsConnectPromiseRef = ref<Promise<void> | null>(null);
const streamStarted = ref(false); const streamStarted = ref(false);
const streamResolverRef = ref<StreamResolver | null>(null); const streamResolverRef = ref<StreamResolver | null>(null);
const loginUser = computed(() => userStore.info ?? {});
const STREAM_END_TEXT = '__END__'; const STREAM_END_TEXT = '__END__';
const SOCKET_CONNECTED_TEXT = '连接成功';
const THINK_OPEN_TAG = '<think>'; const THINK_OPEN_TAG = '<think>';
const THINK_CLOSE_TAG = '</think>'; const THINK_CLOSE_TAG = '</think>';
/** 「自由创作」的取值:选中它时不下发 docType,后端转「AI 创意助手」 */
const DOC_TYPE_FREE = '自由创作';
const form = reactive<FormState>({ const form = reactive<FormState>({
docType: '新闻稿', // 默认自由创作:未选模板就说清想写什么,交给「AI 创意助手」
docType: DOC_TYPE_FREE,
freeContent: '',
eventType: '校园活动', eventType: '校园活动',
theme: '', theme: '',
time: '', time: '',
@@ -285,13 +369,20 @@
number: 800 number: 800
}); });
/**
* 选项必须与上游「内容创作与宣传助手」的 doc_type 枚举逐一对应:
* 新闻稿 / 活动总结 / 微信推文 / 通知公告。
* 多给一个上游没有的值,点下去就是一次必失败的提交(实测返回
* `doc_type in input form must be one of the following: [...]`),
* 所以这里不提供「宣传稿」「发言稿」「短视频脚本」这类上游没有的文体。
*/
const docTypeOptions = [ const docTypeOptions = [
// 自由创作:表单只留一个内容输入框,提交时不带 docType
{ label: '自由创作(不套模板)', value: DOC_TYPE_FREE },
{ label: '新闻稿', value: '新闻稿' }, { label: '新闻稿', value: '新闻稿' },
{ label: '公众号推文', value: '公众号推文' },
{ label: '宣传稿', value: '宣传稿' },
{ label: '活动总结', value: '活动总结' }, { label: '活动总结', value: '活动总结' },
{ label: '发言稿', value: '发言稿' }, { label: '公众号推文', value: '微信推文' },
{ label: '短视频脚本', value: '短视频脚本' } { label: '通知公告', value: '通知公告' }
]; ];
const eventTypeOptions = [ const eventTypeOptions = [
@@ -303,7 +394,13 @@
{ label: '品牌项目', value: '品牌项目' } { label: '品牌项目', value: '品牌项目' }
]; ];
/** 选了文稿模板才展开其余可选参数 */
const templateMode = computed(() => !!form.docType && form.docType !== DOC_TYPE_FREE);
const canSubmit = computed(() => { const canSubmit = computed(() => {
if (!templateMode.value) {
return !!form.freeContent.trim();
}
return !!(form.theme.trim() || form.highlights.trim() || uploadedFiles.value.length); return !!(form.theme.trim() || form.highlights.trim() || uploadedFiles.value.length);
}); });
@@ -374,6 +471,13 @@
}); });
const buildPayload = (): ManuscriptGenParams => { const buildPayload = (): ManuscriptGenParams => {
if (!templateMode.value) {
// 自由创作:只发一段内容,后端见 docType 为空就转「AI 创意助手」
return {
docType: '',
userContent: form.freeContent.trim()
};
}
return { return {
docType: form.docType, docType: form.docType,
eventType: form.eventType, eventType: form.eventType,
@@ -412,20 +516,24 @@
return ''; return '';
}; };
const stringifyUnknown = (value: unknown) => { // 原本这里有一个 stringifyUnknown(JSON.stringify 整包兜底),已移除:
try { // 它会把 taskId / workflowRunId 等内部标识符渲染进编辑器。
return JSON.stringify(value, null, 2); // 现在统一走 @/utils/ai-result(见 issue 10)。
} catch (e) {
return String(value ?? '');
}
};
const parseResult = (payload: ManuscriptGenResult): ParsedResult => { const parseResult = (payload: ManuscriptGenResult): ParsedResult => {
if (typeof payload === 'string') { if (typeof payload === 'string') {
return { return {
title: form.theme.trim(), title: form.theme.trim(),
content: payload, content: payload
summary: '接口直接返回文本内容' };
}
// 失败态优先:后端失败时 answer 是失败文案(且带 status=failed),
// 不先判失败就会把失败文案当正文渲染(见 issue 10)。
if (isFailedPayload(payload)) {
return {
title: form.theme.trim(),
content: FAILED_TEXT
}; };
} }
@@ -436,7 +544,8 @@
payload.subject, payload.subject,
payload.data?.title payload.data?.title
); );
const content = pickString( // 用 pickDisplayable 而非 pickString:后者会把裸 UUID 当正文
const content = pickDisplayable(
payload.content, payload.content,
payload.text, payload.text,
payload.result, payload.result,
@@ -452,14 +561,15 @@
payload.choices?.[0]?.message?.content payload.choices?.[0]?.message?.content
); );
const summary = Object.keys(payload).length
? `已解析返回字段:${Object.keys(payload).join('、')}`
: '返回对象为空';
return { return {
title: title || form.theme.trim(), title: title || form.theme.trim(),
content: content || stringifyUnknown(payload), // 不再 stringifyUnknown(payload):整包会带出 taskId / workflowRunId 等
summary // 内部标识符,且此 content 会直接进编辑器(见 issue 10)。
content: content || resolveDisplayText({
payload,
scope: 'gxmu/manuscriptGen.parseResult',
candidates: []
})
}; };
}; };
@@ -481,44 +591,10 @@
return payload; return payload;
}; };
const applyParsedResult = ( const applyParsedResult = (payload: ManuscriptGenResult) => {
payload: ManuscriptGenResult,
defaultSummary = '已通过 WebSocket 接收生成结果'
) => {
const parsed = parseResult(payload); const parsed = parseResult(payload);
generatedTitle.value = parsed.title || generatedTitle.value || form.theme.trim(); generatedTitle.value = parsed.title || generatedTitle.value || form.theme.trim();
generatedContent.value = parsed.content; generatedContent.value = parsed.content;
responseSummary.value = parsed.summary || defaultSummary;
};
const shouldTreatAsDirectResult = (payload: unknown) => {
if (!payload) {
return false;
}
if (typeof payload === 'string') {
const text = payload.trim();
if (!text) {
return false;
}
return (
text.startsWith('{') ||
text.startsWith('[') ||
text.includes('\n') ||
text.length > 80
);
}
return typeof payload === 'object';
};
const buildWebSocketUrl = (userId: number) => {
if (/^https?:\/\//.test(API_BASE_URL)) {
const url = new URL(API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
url.pathname = url.pathname.replace(/\/api\/?$/, '');
return `${url.toString().replace(/\/$/, '')}/chat/${userId}`;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/chat/${userId}`;
}; };
const clearStreamResolver = () => { const clearStreamResolver = () => {
@@ -530,57 +606,37 @@
clearStreamResolver(); clearStreamResolver();
}; };
const rejectStream = (error: Error) => {
streamResolverRef.value?.reject(error);
clearStreamResolver();
};
const createStreamWaiter = () => { const createStreamWaiter = () => {
streamStarted.value = false; streamStarted.value = false;
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve) => {
streamResolverRef.value = { resolve, reject }; streamResolverRef.value = { resolve };
}); });
}; };
const disconnectWebSocket = () => { const appendAssistantChunk = (chunk: string, reset?: boolean) => {
wsConnectPromiseRef.value = null;
wsConnected.value = false;
const socket = webSocketRef.value;
webSocketRef.value = null;
if (
socket &&
(socket.readyState === WebSocket.OPEN ||
socket.readyState === WebSocket.CONNECTING)
) {
socket.close();
}
};
const appendAssistantChunk = (chunk: string, conversationId?: string) => {
if (!chunk) { if (!chunk) {
return; return;
} }
if (conversationId) {
activeConversationId.value = conversationId;
}
if (!streamStarted.value) { if (!streamStarted.value) {
streamStarted.value = true; streamStarted.value = true;
generatedTitle.value = generatedTitle.value || form.theme.trim(); generatedTitle.value = generatedTitle.value || form.theme.trim();
generatedContent.value = ''; generatedContent.value = '';
} }
// reset=true 表示「丢弃已累积的片段,只用本条内容」。
// 后端在失败收场时带这个标志覆盖半截内容;不处理就会变成
// 「半截正文 + 生成失败,请稍后重试」(见 issue 03 / 10)。
if (reset) {
generatedContent.value = chunk;
} else {
generatedContent.value += chunk; generatedContent.value += chunk;
responseSummary.value = '正在通过 WebSocket 接收流式内容'; }
}; };
const finishStream = (conversationId?: string) => { const finishStream = () => {
if (conversationId) {
activeConversationId.value = conversationId;
}
const payload = parseSerializedResult(generatedContent.value); const payload = parseSerializedResult(generatedContent.value);
applyParsedResult(payload, '已通过 WebSocket 接收生成结果'); applyParsedResult(payload);
loading.value = false; loading.value = false;
resolveStream(); resolveStream();
disconnectWebSocket();
}; };
const handleSocketMessage = (raw: string) => { const handleSocketMessage = (raw: string) => {
@@ -590,7 +646,7 @@
try { try {
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (data?.answer === STREAM_END_TEXT) { if (data?.answer === STREAM_END_TEXT) {
finishStream(data.conversationId); finishStream();
return; return;
} }
if (data?.loading === 'true' || data?.loading === true) { if (data?.loading === 'true' || data?.loading === true) {
@@ -598,98 +654,26 @@
} }
const chunk = pickRawStreamString(data?.notice, data?.answer); const chunk = pickRawStreamString(data?.notice, data?.answer);
if (typeof chunk === 'string' && chunk.length > 0) { if (typeof chunk === 'string' && chunk.length > 0) {
appendAssistantChunk(chunk, data?.conversationId); appendAssistantChunk(chunk, data?.reset === 'true' || data?.reset === true);
} }
} catch (e) { } catch (e) {
appendAssistantChunk(raw); appendAssistantChunk(raw);
} }
}; };
const connectWebSocket = async () => {
const userId = loginUser.value.userId;
if (!userId) {
return Promise.reject(new Error('未获取到当前登录用户'));
}
const currentSocket = webSocketRef.value;
if (currentSocket?.readyState === WebSocket.OPEN && wsConnected.value) {
return;
}
if (
(currentSocket?.readyState === WebSocket.OPEN ||
currentSocket?.readyState === WebSocket.CONNECTING) &&
wsConnectPromiseRef.value
) {
return wsConnectPromiseRef.value;
}
let cleanupPending = () => undefined;
const connectPromise = new Promise<void>((resolve, reject) => {
let settled = false;
const socket = new WebSocket(buildWebSocketUrl(userId));
cleanupPending = () => {
if (wsConnectPromiseRef.value === connectPromise) {
wsConnectPromiseRef.value = null;
}
};
socket.onopen = () => {
wsConnected.value = false;
};
socket.onmessage = (event) => {
const raw = String(event.data || '');
if (raw === SOCKET_CONNECTED_TEXT) {
wsConnected.value = true;
if (!settled) {
settled = true;
cleanupPending();
resolve();
}
return;
}
handleSocketMessage(raw);
};
socket.onclose = () => {
wsConnected.value = false;
if (webSocketRef.value === socket) {
webSocketRef.value = null;
}
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('稿件生成连接已关闭'));
return;
}
if (loading.value) {
rejectStream(new Error('稿件生成连接已关闭'));
loading.value = false;
}
cleanupPending();
};
socket.onerror = () => {
wsConnected.value = false;
if (!settled) {
settled = true;
cleanupPending();
reject(new Error('稿件生成连接失败'));
return;
}
if (loading.value) {
rejectStream(new Error('稿件生成连接失败'));
loading.value = false;
}
};
webSocketRef.value = socket;
});
wsConnectPromiseRef.value = connectPromise;
return connectPromise;
};
const clearGenerated = () => { const clearGenerated = () => {
generatedTitle.value = ''; generatedTitle.value = '';
generatedContent.value = ''; generatedContent.value = '';
responseSummary.value = ''; // 排版结果是这份 markdown 的产物,正文换了就不能留着旧的
richContentHtml.value = '';
savedSnapshot.value = '';
contentTab.value = 'markdown';
}; };
const resetForm = () => { const resetForm = () => {
form.docType = '新闻稿'; // 重置回自由创作,而不是上一次的模板
form.docType = DOC_TYPE_FREE;
form.freeContent = '';
form.eventType = '校园活动'; form.eventType = '校园活动';
form.theme = ''; form.theme = '';
form.time = ''; form.time = '';
@@ -701,6 +685,12 @@
}; };
const fillDemo = (type: DemoType) => { const fillDemo = (type: DemoType) => {
if (type === 'free') {
form.docType = DOC_TYPE_FREE;
form.freeContent =
'帮我写一段迎新晚会的主持人开场词,口语化一点,时长两分钟左右,面向大一新生。';
return;
}
if (type === 'news') { if (type === 'news') {
form.theme = '广西医科大学青年志愿者开展“清洁校园”志愿服务活动'; form.theme = '广西医科大学青年志愿者开展“清洁校园”志愿服务活动';
form.docType = '新闻稿'; form.docType = '新闻稿';
@@ -715,7 +705,7 @@
} }
if (type === 'wechat') { if (type === 'wechat') {
form.theme = '青春集结!这场校园科技文化节亮点抢先看'; form.theme = '青春集结!这场校园科技文化节亮点抢先看';
form.docType = '公众号推文'; form.docType = '微信推文';
form.eventType = '校园活动'; form.eventType = '校园活动';
form.time = '2026年4月中旬'; form.time = '2026年4月中旬';
form.location = '大学生活动中心'; form.location = '大学生活动中心';
@@ -725,14 +715,136 @@
form.number = 1200; form.number = 1200;
return; return;
} }
form.theme = '在学校共青团工作会议上的发言提纲'; form.theme = '关于举办2026年广西医科大学青年志愿服务项目大赛的通知';
form.docType = '发言稿'; form.docType = '通知公告';
form.eventType = '会议论坛'; form.eventType = '志愿服务';
form.time = '2026年春季学期工作会议'; form.time = '2026年4月10日至5月20日';
form.location = '学校综合楼报告厅'; form.location = '各学院团委、大学生活动中心';
form.participants = '各学院团委书记、团干部代表'; form.participants = '全校各团支部、青年志愿者服务队';
form.highlights = '总结上阶段工作,分析问题不足,部署下一阶段思想引领、基层组织建设和实践育人重点任务。'; form.highlights =
form.number = 1500; '说明报名条件、赛程安排、材料提交截止时间和联系方式,语气正式、条目清晰。';
form.number = 600;
};
/** 只留文本、丢掉标签与空白,用来判断两份富文本是不是同一份内容 */
const normalizeForCompare = (html: string) =>
String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, '');
/** 编辑器里的内容是否已经存成稿件(按文本比,编辑器规整过标签属性也算同一份) */
const savedUpToDate = computed(
() =>
!!savedSnapshot.value &&
savedSnapshot.value === normalizeForCompare(richContentHtml.value)
);
/** 保存按钮的可用状态:没内容、正在存、这份已经存过,都不给点 */
const saveDisabled = computed(
() =>
savingManuscript.value ||
!hasRichText(richContentHtml.value) ||
savedUpToDate.value
);
/** 编辑器里的“空”:wangEditor 空内容是一段 `<p><br></p>`,不能只看字符串长度 */
const hasRichText = (html: string) => {
const text = String(html || '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !!text || /<img\b/i.test(String(html || ''));
};
/**
* 一键排版:把生成结果按原文的标题层级、段落、列表、表格转成 HTML,
* 灌进富文本编辑器。
* - 用 gfm 插件,和页面上 Markdown 预览同一套解析(表格不会漏成管道符);
* - 不把生成标题拼进正文:标题有独立字段,导出 Word 时也只在导出件里拼;
* - 不落库:保存是用户在编辑器里确认过的第二次动作(见 saveAsManuscript)。
*/
const formatRichText = () => {
if (loading.value) {
message.warning('正在生成中,暂不能排版');
return;
}
if (!markdownContent.value) {
message.warning('暂无可排版的正文内容');
return;
}
if (markdownContent.value === FAILED_TEXT) {
message.error('生成失败的内容不能排版,请重新生成');
return;
}
const nextHtml = markdownToHtml(markdownContent.value, [
gfm({ locale: zh_HansGfm })
]);
if (!nextHtml.trim()) {
message.warning('当前内容转换不出富文本,请检查正文');
return;
}
const apply = () => {
richContentHtml.value = nextHtml;
// 排版换了内容,之前那次保存的状态就作废
savedSnapshot.value = '';
contentTab.value = 'rich';
message.success('已按原文排版放进富文本编辑器,请检查后保存');
};
// 编辑器里有内容且不是同一份排版(用户改过),才需要确认覆盖。
// 比的是「文本」而不是 HTML:wangEditor 会把 setHtml 进来的标签属性、
// 空白重新规整一遍,按 HTML 比会把「原样再排一次」也判成要覆盖。
if (
hasRichText(richContentHtml.value) &&
normalizeForCompare(richContentHtml.value) !== normalizeForCompare(nextHtml)
) {
Modal.confirm({
title: '覆盖编辑器里的内容?',
content: '富文本编辑器里已有内容,重新排版会覆盖掉当前改动。',
okText: '覆盖',
cancelText: '取消',
onOk: apply
});
return;
}
apply();
};
/**
* 保存为稿件:入库一律写 html,和「编辑稿件」弹窗的口径一致。
* add 接口不回 id,所以这里只负责“存成一条新稿件”,不冒充“再编辑同一篇”:
* 存过之后按钮置灰,改过正文才会重新可点(避免连点存出多条草稿)。
*/
const saveAsManuscript = async () => {
const title = (generatedTitle.value || form.theme).trim();
if (!title) {
message.warning('请先填写生成标题,稿件需要标题');
return;
}
if (!hasRichText(richContentHtml.value)) {
message.warning('富文本里还没有内容,请先点「一键排版」');
return;
}
if (savedUpToDate.value) {
message.info('这份排版已经保存过了,改完再点可以另存一条');
return;
}
savingManuscript.value = true;
try {
const msg = await addManuscript({
title,
content: richContentHtml.value,
contentFormat: 'html',
// 后端 status 是 Integer(列表里 0 待审核 / 1 显示),前端类型写成 string 是历史遗留
status: 1 as unknown as Manuscript['status'],
sortNumber: 0
});
savedSnapshot.value = normalizeForCompare(richContentHtml.value);
message.success(msg || '已保存为稿件');
} catch (e: any) {
message.error(e.message || '保存稿件失败');
} finally {
savingManuscript.value = false;
}
}; };
const removeUploadedFile = (index: number) => { const removeUploadedFile = (index: number) => {
@@ -776,35 +888,54 @@
const handleGenerate = async () => { const handleGenerate = async () => {
if (!canSubmit.value) { if (!canSubmit.value) {
message.warning('请至少填写稿件标题、亮点内容或上传附件中的一项'); message.warning(
templateMode.value
? '请至少填写稿件标题、亮点内容或上传附件中的一项'
: '请先描述你想创作的内容'
);
return; return;
} }
loading.value = true; loading.value = true;
clearGenerated(); clearGenerated();
generatedTitle.value = form.theme.trim(); generatedTitle.value = form.theme.trim();
responseSummary.value = '正在建立 WebSocket 连接...';
try { try {
await connectWebSocket(); // 一律走 HTTP 流式(/stream),不再先连 WebSocket:
responseSummary.value = '连接成功,已提交生成任务,等待流式返回...'; // 触发生成的那个 POST 在整段生成期间是零字节的,会被链路上的 60s 空闲
// 超时掐成 504 Gateway Time-out(自由创作、不选文体时最先撞上)。
// /stream 每 15s 有后端心跳续命,正文逐段喂给原来的帧处理,
// 结构化结果从末帧 {type:'final'} 取。
const payload = buildPayload();
const waitStream = createStreamWaiter(); const waitStream = createStreamWaiter();
const result = await manuscriptGen(buildPayload()); const result = await runManuscriptGenStream(
if (!streamStarted.value && shouldTreatAsDirectResult(result)) { payload as unknown as Record<string, unknown>,
applyParsedResult( undefined,
typeof result === 'string' ? parseSerializedResult(result) : (result as ManuscriptGenResult), (raw) => handleSocketMessage(raw)
'接口直接返回生成结果' );
if (result) {
applyParsedResult(
typeof result === 'string'
? parseSerializedResult(result)
: (result as ManuscriptGenResult)
); );
loading.value = false;
resolveStream();
disconnectWebSocket();
} else {
await waitStream;
} }
// 兜底:流结束但没收到 __END__ 时也别把页面卡在加载中
resolveStream();
await waitStream;
const text = markdownContent.value.trim();
if (!text) {
message.warning('这次没有生成出内容,请重试');
} else if (text === FAILED_TEXT) {
message.error('生成失败,请稍后重试');
} else {
message.success('内容生成完成'); message.success('内容生成完成');
}
} catch (e: any) { } catch (e: any) {
disconnectWebSocket(); message.error(e.message || '内容生成失败');
} finally {
// 收尾以「请求结束」为准,不能只靠 __END__ 帧:网关返回空流、
// 上游中途报错时都不会有 __END__,只等它会把生成按钮永久卡在 loading。
clearStreamResolver(); clearStreamResolver();
loading.value = false; loading.value = false;
message.error(e.message || '内容生成失败');
} }
}; };
@@ -934,9 +1065,14 @@
push('/ai'); push('/ai');
}; };
/** 写作记录统一在助手页查看,这里只带类型跳过去 */
const goHistory = () => {
push({ path: '/ai', query: { type: 'writing' } });
};
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearStreamResolver(); clearStreamResolver();
disconnectWebSocket(); editorRef.value?.destroy();
}); });
</script> </script>
@@ -1028,13 +1164,6 @@
gap: 12px; gap: 12px;
} }
.upload-tip {
margin-top: 10px;
color: #5f7f9f;
font-size: 12px;
line-height: 1.7;
}
.file-list { .file-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1094,26 +1223,6 @@
color: #1590ff; color: #1590ff;
} }
.hint-block {
margin-top: 18px;
padding: 14px 16px;
border-radius: 14px;
background: #f5fbff;
border: 1px solid #c7e8ff;
}
.hint-title {
margin-bottom: 6px;
font-weight: 600;
color: #1590ff;
}
.hint-text {
color: #5f7f9f;
line-height: 1.7;
font-size: 13px;
}
.result-card { .result-card {
min-height: 100%; min-height: 100%;
} }
@@ -1124,12 +1233,6 @@
gap: 10px; gap: 10px;
} }
.response-meta {
padding: 12px 14px;
border-radius: 14px;
background: #f5fbff;
}
.markdown-shell { .markdown-shell {
min-height: 360px; min-height: 360px;
border: 1px solid #c7e8ff; border: 1px solid #c7e8ff;
@@ -1165,18 +1268,46 @@
color: #1f2d3d; color: #1f2d3d;
} }
.meta-label { .content-tabs :deep(.ant-tabs-nav) {
margin-bottom: 4px; margin-bottom: 10px;
font-weight: 600;
color: #1f2d3d;
} }
.meta-value { .rich-shell {
color: #5f7f9f; border: 1px solid #c7e8ff;
line-height: 1.7; border-radius: 16px;
word-break: break-all; overflow: hidden;
background: #fffdfb;
} }
.rich-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #eef4f9;
background: #f5fbff;
}
.rich-bar-tip {
color: #5f7f9f;
font-size: 12px;
line-height: 1.6;
}
.rich-toolbar {
border-bottom: 1px solid #f0f0f0;
}
.rich-editor {
min-height: 360px;
}
.rich-editor :deep(.w-e-text-container) {
min-height: 360px;
}
.full-width { .full-width {
width: 100%; width: 100%;
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,904 @@
/**
* 审核列表「查看数据」详情的申报表配置。
*
* sections 决定申报表的字段分组与展示顺序,materialFields 声明需要在上传材料
* 中展示的附件字段,未列出的字段会兜底追加到「其他信息」分组,避免数据丢失。
*/
export interface ModuleSection {
// 分组标题
title: string;
// 分组内字段(按展示顺序)
fields: string[];
}
export interface ModuleMaterialTable {
// 明细数组字段
field: string;
// 明细中保存附件地址的字段
fileField: string;
// 明细标题字段
titleField?: string;
// 材料分组标题
label: string;
}
export interface ModuleDetailConfig {
// 模块名称
title: string;
// 申报表名称,用于拼接表单标题
sheetName: string;
// 字段中文名
labels: Record<string, string>;
// 申报表分组
sections: ModuleSection[];
// 附件字段(上传材料)
materialFields: string[];
// 明细数组中的附件
materialTables?: ModuleMaterialTable[];
// 额外的材料数据来源
materialSource?: 'tzbcy';
}
/**
* 通用字段中文名
*/
export const COMMON_LABELS: Record<string, string> = {
year: '年份',
id: 'ID',
title: '标题',
content: '内容',
contentFormat: '正文格式',
cover: '封面',
createTime: '提交时间',
updateTime: '更新时间',
status: '状态',
sortNumber: '排序',
provinceCity: '省市',
schoolName: '学校名称',
projectName: '项目名称',
projectType: '项目类型',
projectGroup: '项目分组',
publicProjectType: '公开展示项目类型',
publicProjectGroup: '公开展示项目分组',
leader: '负责人',
phone: '联系电话',
teamMembers: '团队成员',
advisors: '指导老师',
projectBrief: '项目简介',
socialValue: '社会价值',
practiceProcess: '实践过程',
innovationMeaning: '创新意义',
developmentProspect: '发展前景',
teamCooperation: '团队协作情况',
projectMaterials: '项目介绍材料',
otherProofs: '其他相关证明材料',
projectSummary: '项目总结',
teamIntro: '团队介绍',
teamSlogan: '团队口号',
practiceLog: '实践日志',
name: '姓名',
gender: '性别',
nation: '民族',
photo: '电子版照片',
birthMonth: '出生年月',
politics: '政治面貌',
nativePlace: '籍贯',
wechat: '微信号',
email: '邮箱',
qq: 'QQ',
idCardNo: '身份证号',
hobby: '兴趣爱好',
willingToWorkInGuangxi: '是否愿意留桂工作',
schoolInfo: '学校信息',
leaguePosition: '团内职务',
resume: '个人简历',
awards: '获得奖励',
academicPerformance: '学习成绩',
secondaryLeagueOpinion: '二级团组织意见',
secondaryLeagueOpinionDate: '二级团组织意见日期',
secondaryPartyOpinion: '二级党组织意见',
secondaryPartyOpinionDate: '二级党组织意见日期',
schoolLeagueOpinion: '学校团组织意见',
schoolLeagueOpinionDate: '学校团组织意见日期',
applyType: '申报类别',
education: '学历',
position: '职务',
unit: '工作单位',
experience: '主要经历',
mainStory: '主要事迹',
leagueOpinion: '团组织意见',
partyOpinion: '党组织意见',
schoolOpinion: '学校意见',
collegeClass: '学院班级',
branch: '团支部名称',
eduEval: '教育评议情况',
serialNo: '序号',
archiveInCurrentOrg: '团籍是否在本组织',
joinMonth: '入团时间',
memberRecords: '团员记录',
growthArchives: '成长档案',
orgName: '组织名称',
memberTotal: '团员总数',
memberDeveloped2024: '上一年度发展团员数',
smartSystemLogin: '智慧团建系统录入情况',
committeeCount: '团委委员数',
fulltimeCadreCount: '专职团干部数',
parttimeCadreCount: '兼职团干部数',
lastElectionTime: '最近一次换届时间',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应缴团费',
feePaid2024: '上一年度实缴团费',
branchCount: '团支部数量',
standardizedWork2024: '上一年度标准化建设开展情况',
recommendActivist2024: '上一年度推优入党人数',
activistConfirmed: '入党积极分子确定情况',
recommendDevTarget2024: '上一年度推荐发展对象人数',
devTargetConfirmed: '发展对象确定情况',
honorsFiveYears: '近五年获奖情况',
workSummaryThreeYears: '近三年工作总结',
branchName: '团支部名称',
secondOrg: '所属二级党组织',
secretary: '团支部书记',
contact: '联系方式',
establishTime: '成立时间',
branchCommitteeMeetingCount: '支委会次数',
branchMemberMeetingCount: '支部大会次数',
eduEvalDone: '教育评议是否完成',
annualRegDone: '年度团籍注册是否完成',
classCount: '班级数量',
smartSystem100: '智慧团建系统覆盖率',
topicName: '课题名称',
collegeGradeClass: '学院年级班级',
advisor: '指导老师',
topicType: '课题类别',
guidanceTeachers: '指导教师组',
applicants: '申请人信息',
budgets: '经费预算',
rationale: '立项依据',
teacherOpinion: '指导教师意见',
teacherSign: '指导教师签名',
reviewOpinion: '评审意见',
reviewScore: '评审成绩',
reviewLeaderSign: '评审负责人签名',
collegeOpinion: '学院意见',
promiseSigner: '承诺签字人',
promiseSignature: '承诺书签名',
reviewCollege: '评审学院',
reviewReporter: '汇报人',
reviewDate: '评审日期',
reviewMembers: '评审成员',
fundingBudgetFile: '经费预算表',
fundingAllocationFile: '经费划拨明细表',
midtermRemark: '中期检查说明',
midtermMaterials: '中期检查材料',
finalRemark: '结题说明',
finalMaterials: '结题材料',
archiveAwards: '获奖情况',
archiveProofs: '佐证材料',
archiveRecords: '成果归档',
grade: '年级',
className: '班级',
major: '专业',
role: '角色',
item: '项目',
amount: '金额',
reason: '原因',
dept: '部门',
sign: '签名',
department: '部门',
remark: '备注',
type: '类型',
recordTime: '时间',
archiveTime: '时间',
identity: '身份',
organization: '所在组织',
memberNo: '团员编号',
currentDutyTime: '现任职务时间',
cadreYears: '从事团学工作年限',
assessment2024: '上一年度考核情况',
volunteerRegTime: '志愿汇注册时间',
cadreExperience: '团学工作经历',
joinTime: '入团时间',
collegeMajorClass: '学院专业班级',
eduEval2024: '上一年度教育评议情况',
smartSystem: '智慧团建系统录入情况',
totalVolunteerHours: '累计志愿服务时长',
volunteerHours2024: '上一年度志愿服务时长',
duty: '职务',
college: '学院',
gradeMajor: '年级专业',
birthDate: '出生日期',
branchOrganizationName: '所属团支部',
awardName: '获奖名称',
awardTime: '获奖时间',
proofFile: '证明材料',
archiveOrder: '序号'
};
/**
* 五四类申报表的意见栏分组
*/
const OPINION_SECTION: ModuleSection = {
title: '意见栏',
fields: ['leagueOpinion', 'partyOpinion', 'schoolOpinion']
};
export const MODULE_DETAIL_CONFIGS: Record<string, ModuleDetailConfig> = {
gxmu_sjqn: {
title: '十佳青年岗位能手',
sheetName: '十佳青年岗位能手',
labels: {
applyType: '申报类别',
education: '学历',
position: '职务',
title: '职称',
unit: '所在单位',
awards: '近三年曾获校级及以上奖励',
experience: '工作(学习)经历',
mainStory: '主要事迹',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'applyType',
'name',
'gender',
'birthMonth',
'nation',
'politics',
'education',
'position',
'title',
'unit'
]
},
{
title: '主要事迹与经历',
fields: ['awards', 'experience', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_sjtbzbsj: {
title: '十佳团支部书记',
sheetName: '十佳团支部书记',
labels: {
collegeClass: '学院班级/单位科室',
branch: '所在团支部',
eduEval: '上一年度团员教育评议等次',
awards: '近三年曾获校级及以上奖励',
experience: '工作(学习)经历',
mainStory: '主要事迹',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'birthMonth',
'nation',
'politics',
'collegeClass',
'branch',
'eduEval'
]
},
{
title: '主要事迹与经历',
fields: ['awards', 'experience', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_wshqtw: {
title: '五四红旗团委',
sheetName: '五四红旗团委',
labels: {
orgName: '二级团组织全称',
leader: '负责人',
phone: '联系电话',
memberTotal: '现有团员总数',
memberDeveloped2024: '上一年度发展团员人数',
smartSystemLogin: '是否已登录智慧团建系统',
committeeCount: '团委委员人数',
fulltimeCadreCount: '专职团干部数',
parttimeCadreCount: '兼职团干部数',
lastElectionTime: '团委最近一次换届时间',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应上缴团费',
feePaid2024: '上一年度实际上缴团费',
branchCount: '团支部数',
standardizedWork2024: '是否开展规范化建设工作',
recommendActivist2024: '推荐入党积极分子人数',
activistConfirmed: '确定为入党积极分子数',
recommendDevTarget2024: '推荐党的发展对象人数',
devTargetConfirmed: '确定为党的发展对象数',
honorsFiveYears: '近五年获得校级及以上荣誉情况',
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{ title: '组织信息', fields: ['year', 'orgName', 'leader', 'phone'] },
{
title: '团员与团干部情况',
fields: [
'memberTotal',
'memberDeveloped2024',
'smartSystemLogin',
'committeeCount',
'fulltimeCadreCount',
'parttimeCadreCount',
'lastElectionTime'
]
},
{
title: '团费收缴情况',
fields: [
'feeReceivable2024',
'feeReceived2024',
'feePayable2024',
'feePaid2024'
]
},
{
title: '团支部与推优情况',
fields: [
'branchCount',
'recommendActivist2024',
'activistConfirmed',
'recommendDevTarget2024',
'devTargetConfirmed'
]
},
{
title: '工作与荣誉',
fields: [
'standardizedWork2024',
'honorsFiveYears',
'workSummaryThreeYears'
]
},
{ title: '意见栏', fields: ['partyOpinion', 'schoolOpinion'] }
],
materialFields: []
},
gxmu_wshqtzb: {
title: '五四红旗团支部',
sheetName: '五四红旗团支部',
labels: {
branchName: '团支部全称',
secondOrg: '所属二级团组织',
secretary: '团支部书记',
politics: '政治面貌',
contact: '联系方式',
establishTime: '成立时间',
lastElectionTime: '最近一次换届时间',
smartSystemLogin: '是否已登录智慧团建系统',
memberTotal: '现有团员总数',
memberDeveloped2024: '上一年度发展团员数',
feeReceivable2024: '上一年度应收团费',
feeReceived2024: '上一年度实收团费',
feePayable2024: '上一年度应上缴团费',
feePaid2024: '上一年度实际上缴团费',
recommendActivist2024: '推荐入党积极分子人数',
activistConfirmed: '确定为入党积极分子数',
recommendDevTarget2024: '推荐党的发展对象人数',
devTargetConfirmed: '确定为党的发展对象数',
branchCommitteeMeetingCount: '团支部委员会会议召开次数',
branchMemberMeetingCount: '团支部团员大会召开次数',
eduEvalDone: '是否开展团员教育评议',
annualRegDone: '是否开展团员年度团籍注册',
classCount: '开展团课次数',
smartSystem100: '是否100%录入智慧团建',
honorsFiveYears: '近五年获得院级及以上荣誉情况',
workSummaryThreeYears: '近三年开展的主要工作及取得的效果',
leagueOpinion: '所在二级团组织意见',
partyOpinion: '所在单位党组织意见',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '支部信息',
fields: [
'year',
'branchName',
'secondOrg',
'secretary',
'politics',
'contact',
'establishTime',
'lastElectionTime'
]
},
{
title: '团员与团费情况',
fields: [
'smartSystemLogin',
'memberTotal',
'memberDeveloped2024',
'feeReceivable2024',
'feeReceived2024',
'feePayable2024',
'feePaid2024'
]
},
{
title: '推优情况',
fields: [
'recommendActivist2024',
'activistConfirmed',
'recommendDevTarget2024',
'devTargetConfirmed'
]
},
{
title: '组织生活',
fields: [
'branchCommitteeMeetingCount',
'branchMemberMeetingCount',
'eduEvalDone',
'annualRegDone',
'classCount',
'smartSystem100'
]
},
{
title: '工作与荣誉',
fields: ['honorsFiveYears', 'workSummaryThreeYears']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_yxgqtdgb: {
title: '优秀共青团干部',
sheetName: '优秀共青团干部',
labels: {
identity: '身份',
organization: '所在团组织',
contact: '联系方式',
memberNo: '发展团员编号',
currentDutyTime: '任现团内职务时间',
cadreYears: '担任团干部年限',
assessment2024: '上一年度工作考核结果',
volunteerRegTime: '成为注册志愿者时间',
cadreExperience: '从事团干部经历',
honorsFiveYears: '近五年获得校级及以上荣誉情况',
mainStory: '主要事迹',
leagueOpinion: '所在单位团组织意见',
partyOpinion: '所在单位党组织意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'nation',
'birthMonth',
'politics',
'position',
'identity',
'organization',
'contact',
'memberNo'
]
},
{
title: '团干部履历',
fields: [
'currentDutyTime',
'cadreYears',
'assessment2024',
'volunteerRegTime',
'cadreExperience'
]
},
{
title: '主要事迹与荣誉',
fields: ['honorsFiveYears', 'mainStory']
},
{ title: '意见栏', fields: ['leagueOpinion', 'partyOpinion'] }
],
materialFields: []
},
gxmu_yxgqty: {
title: '优秀共青团员',
sheetName: '优秀共青团员',
labels: {
joinTime: '入团时间',
collegeMajorClass: '所在学院、专业、班级',
position: '职务',
volunteerRegTime: '成为注册志愿者时间',
eduEval2024: '上一年度团员教育评议等次',
smartSystem: '是否已登录智慧团建系统',
contact: '联系电话',
totalVolunteerHours: '累计志愿服务时长',
volunteerHours2024: '上一年度志愿服务时长',
memberNo: '发展团员编号',
honorsFiveYears: '近五年获得荣誉情况',
mainStory: '主要事迹',
leagueOpinion: '学生社团指导教师意见/所在单位团委',
partyOpinion: '业务指导单位意见/所在单位党组织',
schoolOpinion: '校团委意见'
},
sections: [
{
title: '基本信息',
fields: [
'year',
'name',
'gender',
'nation',
'politics',
'birthMonth',
'joinTime',
'collegeMajorClass',
'position',
'contact',
'memberNo'
]
},
{
title: '志愿服务与教育评议',
fields: [
'volunteerRegTime',
'eduEval2024',
'smartSystem',
'totalVolunteerHours',
'volunteerHours2024'
]
},
{
title: '主要事迹与荣誉',
fields: ['honorsFiveYears', 'mainStory']
},
OPINION_SECTION
],
materialFields: []
},
gxmu_tzbcy_form: {
title: '挑战杯',
sheetName: '挑战杯',
labels: {
projectMaterials: '项目介绍材料',
otherProofs: '其他相关证明材料',
teamMembers: '团队成员',
advisors: '指导老师'
},
sections: [
{
title: '项目信息',
fields: [
'year',
'projectName',
'provinceCity',
'schoolName',
'projectType',
'projectGroup'
]
},
{ title: '团队成员', fields: ['teamMembers'] },
{ title: '指导老师', fields: ['advisors'] },
{
title: '项目说明',
fields: [
'projectBrief',
'socialValue',
'practiceProcess',
'innovationMeaning',
'developmentProspect',
'teamCooperation',
'projectMaterials',
'otherProofs'
]
},
{
title: '公开展示信息',
fields: [
'publicProjectType',
'publicProjectGroup',
'projectSummary',
'teamIntro',
'teamSlogan',
'practiceLog'
]
}
],
materialFields: [],
materialSource: 'tzbcy'
},
gxmu_qmgc_form: {
title: '青马工程',
sheetName: '青马工程',
labels: {
hobby: '兴趣爱好',
willingToWorkInGuangxi: '是否愿意留桂工作',
schoolInfo: '学校信息',
leaguePosition: '团内职务',
resume: '个人简历',
awards: '获得奖励',
academicPerformance: '学习成绩',
secondaryLeagueOpinion: '二级团组织意见',
secondaryLeagueOpinionDate: '二级团组织意见日期',
secondaryPartyOpinion: '二级党组织意见',
secondaryPartyOpinionDate: '二级党组织意见日期',
schoolLeagueOpinion: '学校团组织意见',
schoolLeagueOpinionDate: '学校团组织意见日期'
},
sections: [
{
title: '个人信息',
fields: [
'year',
'name',
'gender',
'nation',
'photo',
'birthMonth',
'politics',
'nativePlace',
'phone',
'wechat',
'email',
'qq',
'idCardNo',
'hobby',
'willingToWorkInGuangxi',
'schoolInfo',
'leaguePosition'
]
},
{
title: '学习与经历',
fields: ['resume', 'awards', 'academicPerformance']
},
{
title: '意见栏',
fields: [
'secondaryLeagueOpinion',
'secondaryLeagueOpinionDate',
'secondaryPartyOpinion',
'secondaryPartyOpinionDate',
'schoolLeagueOpinion',
'schoolLeagueOpinionDate'
]
}
],
materialFields: []
},
gxmu_wxxzx_project: {
title: '未来学术之星',
sheetName: '未来学术之星',
labels: {
topicName: '课题名称',
leader: '课题负责人',
collegeGradeClass: '学院年级班级',
phone: '联系电话',
advisor: '指导老师',
topicType: '课题类别',
guidanceTeachers: '指导教师组',
applicants: '申请人信息',
budgets: '经费预算',
rationale: '立项依据',
teacherOpinion: '指导教师意见',
teacherSign: '指导教师签名',
reviewCollege: '评审学院',
reviewReporter: '汇报人',
reviewDate: '评审日期',
reviewOpinion: '评审意见',
reviewScore: '评审成绩',
reviewLeaderSign: '评审负责人签名',
collegeOpinion: '学院意见',
schoolOpinion: '学校意见',
reviewMembers: '评审成员',
midtermRemark: '中期检查说明',
midtermMaterials: '中期检查材料',
finalRemark: '结题说明',
finalMaterials: '结题材料',
archiveRecords: '成果归档',
archiveAwards: '获奖情况',
archiveProofs: '佐证材料',
promiseSigner: '承诺签字人',
promiseSignature: '承诺书签名',
fundingBudgetFile: '经费预算表',
fundingAllocationFile: '经费划拨明细表',
proofFile: '证明材料'
},
sections: [
{
title: '课题信息',
fields: [
'year',
'topicName',
'leader',
'collegeGradeClass',
'phone',
'advisor',
'topicType'
]
},
{ title: '指导教师组', fields: ['guidanceTeachers'] },
{ title: '申请人信息', fields: ['applicants'] },
{
title: '经费预算',
fields: ['budgets', 'fundingBudgetFile', 'fundingAllocationFile']
},
{ title: '立项依据', fields: ['rationale'] },
{
title: '审核评审',
fields: [
'teacherOpinion',
'teacherSign',
'reviewCollege',
'reviewReporter',
'reviewDate',
'reviewOpinion',
'reviewScore',
'reviewLeaderSign',
'collegeOpinion',
'schoolOpinion'
]
},
{ title: '评审成员', fields: ['reviewMembers'] },
{
title: '中期检查与结题',
fields: [
'midtermRemark',
'midtermMaterials',
'finalRemark',
'finalMaterials'
]
},
{
title: '成果归档',
fields: ['archiveRecords', 'archiveAwards', 'archiveProofs']
},
{
title: '承诺书',
fields: ['promiseSigner', 'promiseSignature']
}
],
materialFields: [
'fundingBudgetFile',
'fundingAllocationFile',
'midtermMaterials',
'finalMaterials',
'archiveProofs'
],
materialTables: [
{
field: 'archiveRecords',
fileField: 'proofFile',
titleField: 'awardName',
label: '成果归档证明材料'
}
]
},
gxmu_tygl_form: {
title: '团员管理',
sheetName: '团员信息登记表',
labels: {
serialNo: '序号',
college: '学院',
className: '班级',
phone: '手机号码',
leaguePosition: '团内职务',
branchOrganizationName: '所属团支部',
idCardNo: '身份证号',
birthDate: '出生日期',
archiveInCurrentOrg: '团籍是否在本组织',
joinMonth: '入团年月',
memberRecords: '团员记录',
growthArchives: '成长档案'
},
sections: [
{
title: '基本信息',
fields: [
'serialNo',
'name',
'gender',
'nation',
'politics',
'college',
'className',
'phone',
'leaguePosition',
'branchOrganizationName',
'idCardNo',
'birthDate',
'archiveInCurrentOrg',
'joinMonth'
]
},
{ title: '团员记录', fields: ['memberRecords'] },
{ title: '成长档案', fields: ['growthArchives'] }
],
materialFields: []
},
cms_manuscript: {
title: '稿件',
sheetName: '稿件',
labels: {
title: '标题',
content: '正文',
contentFormat: '正文格式',
cover: '封面',
status: '状态',
sortNumber: '排序'
},
sections: [
{
title: '基本信息',
fields: ['title', 'status', 'sortNumber']
},
{ title: '封面', fields: ['cover'] },
{ title: '正文', fields: ['content'] }
],
materialFields: []
}
};
// 团员管理模块历史上存在两个模块标识
MODULE_DETAIL_CONFIGS.gxmu_tygl = MODULE_DETAIL_CONFIGS.gxmu_tygl_form;
/**
* 明细数组表头字段中文名
*/
export const TABLE_FIELD_LABELS: Record<string, string> = {
name: '姓名',
gender: '性别',
nation: '民族',
politics: '政治面貌',
birthMonth: '出生年月',
title: '职称',
duty: '职务',
department: '部门',
dept: '部门',
remark: '备注',
college: '学院',
gradeMajor: '年级专业',
grade: '年级',
className: '班级',
major: '专业',
role: '角色',
phone: '联系电话',
sign: '签名',
item: '项目',
amount: '金额',
reason: '原因',
type: '类型',
content: '内容',
recordTime: '时间',
archiveTime: '时间',
awardName: '获奖名称',
awardTime: '获奖时间',
archiveOrder: '序号',
proofFile: '证明材料',
year: '年份'
};
export const getModuleDetailConfig = (module?: string) => {
if (!module) {
return undefined;
}
return MODULE_DETAIL_CONFIGS[module];
};
@@ -15,7 +15,9 @@
<a-tag>{{ moduleName || module || '-' }}</a-tag> <a-tag>{{ moduleName || module || '-' }}</a-tag>
<span>业务ID:</span> <span>业务ID:</span>
<a-tag>{{ pk ?? '-' }}</a-tag> <a-tag>{{ pk ?? '-' }}</a-tag>
<a-button size="small" :loading="loading" @click="loadData">刷新</a-button> <a-button size="small" :loading="loading" @click="loadData"
>刷新</a-button
>
</a-space> </a-space>
<div v-if="flowList.length" class="flow-wrap"> <div v-if="flowList.length" class="flow-wrap">
@@ -23,7 +25,9 @@
<a-step v-for="item in flowList" :key="item.id"> <a-step v-for="item in flowList" :key="item.id">
<template #title> <template #title>
<div class="flow-title"> <div class="flow-title">
<span>{{ item.user?.realName || item.user?.username || '未分配审核人' }}</span> <span>{{
item.user?.realName || item.user?.username || '未分配审核人'
}}</span>
<a-tag :color="getStatusColor(item.status)"> <a-tag :color="getStatusColor(item.status)">
{{ getStatusText(item.status) }} {{ getStatusText(item.status) }}
</a-tag> </a-tag>
@@ -31,14 +35,28 @@
</template> </template>
<template #description> <template #description>
<div class="flow-desc"> <div class="flow-desc">
<div>创建时间:{{ toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss') }}</div> <div
>创建时间:{{
toDateString(item.createTime, 'yyyy-MM-dd HH:mm:ss')
}}</div
>
<div v-if="item.content">审核意见:{{ item.content }}</div> <div v-if="item.content">审核意见:{{ item.content }}</div>
<a-space v-if="canReview(item)" style="margin-top: 12px"> <a-space v-if="canReview(item)" style="margin-top: 12px">
<a-button type="primary" size="small" :loading="submitting" @click="openActionModal('approve', item)"> <a-button
type="primary"
size="small"
:loading="submitting"
@click="openActionModal('approve', item)"
>
通过 通过
</a-button> </a-button>
<a-button danger size="small" :loading="submitting" @click="openActionModal('reject', item)"> <a-button
拒绝 danger
size="small"
:loading="submitting"
@click="openActionModal('reject', item)"
>
驳回
</a-button> </a-button>
</a-space> </a-space>
</div> </div>
@@ -50,19 +68,26 @@
<a-modal <a-modal
v-model:visible="actionVisible" v-model:visible="actionVisible"
:title="actionType === 'approve' ? '审核通过' : '审核拒绝'" :title="actionType === 'approve' ? '审核通过' : '审核驳回'"
ok-text="确认" ok-text="确认"
cancel-text="取消" cancel-text="取消"
:confirm-loading="submitting" :confirm-loading="submitting"
@ok="submitReview" @ok="submitReview"
> >
<a-form layout="vertical"> <a-form
<a-form-item label="审核意见(非必填)"> ref="actionFormRef"
layout="vertical"
:model="actionForm"
:rules="actionRules"
>
<a-form-item label="审核意见" name="content">
<a-textarea <a-textarea
v-model:value="actionContent" v-model:value="actionForm.content"
:rows="4" :rows="4"
:maxlength="500"
show-count
allow-clear allow-clear
placeholder="请输入审核意见" placeholder="请输入审核意见(必填)"
/> />
</a-form-item> </a-form-item>
</a-form> </a-form>
@@ -71,8 +96,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { Empty, message } from 'ant-design-vue'; import { Empty, message } from 'ant-design-vue';
import type { FormInstance } from 'ant-design-vue/es/form';
import { toDateString } from 'ele-admin-pro'; import { toDateString } from 'ele-admin-pro';
import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList'; import { listReviewList, updateReviewList } from '@/api/gxmu/reviewList';
import type { ReviewList } from '@/api/gxmu/reviewList/model'; import type { ReviewList } from '@/api/gxmu/reviewList/model';
@@ -98,9 +124,26 @@ const submitting = ref(false);
const flowList = ref<ReviewList[]>([]); const flowList = ref<ReviewList[]>([]);
const actionVisible = ref(false); const actionVisible = ref(false);
const actionType = ref<'approve' | 'reject'>('approve'); const actionType = ref<'approve' | 'reject'>('approve');
const actionContent = ref(''); const actionFormRef = ref<FormInstance | null>(null);
const actionForm = reactive<{ content: string }>({ content: '' });
const actionTarget = ref<ReviewList | null>(null); const actionTarget = ref<ReviewList | null>(null);
// 审核意见必填(仅空白字符视为未填写)
const actionRules = {
content: [
{
required: true,
validator: (_rule: unknown, value: string) => {
const text = (value || '').trim();
return text
? Promise.resolve()
: Promise.reject(new Error('请输入审核意见'));
},
trigger: 'blur'
}
]
};
const currentStepIndex = computed(() => { const currentStepIndex = computed(() => {
const pendingIndex = flowList.value.findIndex((item) => item.status === 0); const pendingIndex = flowList.value.findIndex((item) => item.status === 0);
if (pendingIndex >= 0) { if (pendingIndex >= 0) {
@@ -115,7 +158,7 @@ const updateVisible = (value: boolean) => {
const getStatusText = (status?: number) => { const getStatusText = (status?: number) => {
if (status === 1) return '通过'; if (status === 1) return '通过';
if (status === 2) return '拒绝'; if (status === 2) return '驳回';
return '待审核'; return '待审核';
}; };
@@ -160,28 +203,32 @@ const openActionModal = (type: 'approve' | 'reject', item: ReviewList) => {
} }
actionType.value = type; actionType.value = type;
actionTarget.value = item; actionTarget.value = item;
actionContent.value = ''; actionForm.content = '';
actionVisible.value = true; actionVisible.value = true;
}; };
const submitReview = () => { const submitReview = () => {
if (!actionTarget.value?.id) { const target = actionTarget.value;
if (!target?.id) {
actionVisible.value = false; actionVisible.value = false;
return; return;
} }
if (!canReview(actionTarget.value)) { if (!canReview(target)) {
actionVisible.value = false; actionVisible.value = false;
message.error('当前记录不是由你审核'); message.error('当前记录不是由你审核');
return; return;
} }
actionFormRef.value
?.validate()
.then(() => {
submitting.value = true; submitting.value = true;
updateReviewList({ return updateReviewList({
id: actionTarget.value.id, id: target.id,
status: actionType.value === 'approve' ? 1 : 2, status: actionType.value === 'approve' ? 1 : 2,
module: actionTarget.value.module, module: target.module,
pk: actionTarget.value.pk, pk: target.pk,
sortNumber: actionTarget.value.sortNumber, sortNumber: target.sortNumber,
content: actionContent.value || undefined content: actionForm.content.trim()
}) })
.then((msg) => { .then((msg) => {
message.success(msg); message.success(msg);
@@ -195,6 +242,8 @@ const submitReview = () => {
.finally(() => { .finally(() => {
submitting.value = false; submitting.value = false;
}); });
})
.catch(() => {});
}; };
watch( watch(
@@ -206,7 +255,7 @@ watch(
flowList.value = []; flowList.value = [];
actionVisible.value = false; actionVisible.value = false;
actionTarget.value = null; actionTarget.value = null;
actionContent.value = ''; actionForm.content = '';
} }
}, },
{ immediate: true } { immediate: true }
@@ -33,7 +33,7 @@
v-model:value="form.pk" v-model:value="form.pk"
/> />
</a-form-item> </a-form-item>
<a-form-item label="状态(0不通过 1通过)" name="status"> <a-form-item label="状态(0驳回 1通过)" name="status">
<a-radio-group v-model:value="form.status"> <a-radio-group v-model:value="form.status">
<a-radio :value="1">显示</a-radio> <a-radio :value="1">显示</a-radio>
<a-radio :value="0">隐藏</a-radio> <a-radio :value="0">隐藏</a-radio>
+26 -6
View File
@@ -23,7 +23,7 @@
<template v-if="column.key === 'status'"> <template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0">待审核</a-tag> <a-tag v-if="record.status === 0">待审核</a-tag>
<a-tag v-else-if="record.status === 1" color="green">通过</a-tag> <a-tag v-else-if="record.status === 1" color="green">通过</a-tag>
<a-tag v-else-if="record.status === 2" color="red">拒绝</a-tag> <a-tag v-else-if="record.status === 2" color="red">驳回</a-tag>
</template> </template>
<template v-if="column.key === 'action'"> <template v-if="column.key === 'action'">
<a-space> <a-space>
@@ -36,12 +36,17 @@
</ele-pro-table> </ele-pro-table>
</a-card> </a-card>
<ReviewListEdit v-model:visible="showEdit" :data="current" @done="reload" /> <ReviewListEdit
v-model:visible="showEdit"
:data="current"
@done="reload"
/>
<ModuleDataModal <ModuleDataModal
v-model:visible="showDataVisible" v-model:visible="showDataVisible"
:module="dataCurrent?.module" :module="dataCurrent?.module"
:module-name="dataCurrent?.moduleName" :module-name="dataCurrent?.moduleName"
:pk="dataCurrent?.pk" :pk="dataCurrent?.pk"
@done="reload"
/> />
<ReviewFlowModal <ReviewFlowModal
v-model:visible="showFlowVisible" v-model:visible="showFlowVisible"
@@ -60,13 +65,22 @@ import { message, Modal } from 'ant-design-vue';
import { ExclamationCircleOutlined } from '@ant-design/icons-vue'; import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
import type { EleProTable } from 'ele-admin-pro'; import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro'; import { toDateString } from 'ele-admin-pro';
import type { DatasourceFunction, ColumnItem } from 'ele-admin-pro/es/ele-pro-table/types'; import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue'; import Search from './components/search.vue';
import ReviewListEdit from './components/reviewListEdit.vue'; import ReviewListEdit from './components/reviewListEdit.vue';
import ModuleDataModal from './components/moduleDataModal.vue'; import ModuleDataModal from './components/moduleDataModal.vue';
import ReviewFlowModal from './components/reviewFlowModal.vue'; import ReviewFlowModal from './components/reviewFlowModal.vue';
import { pageGroupReviewList, removeBatchReviewList } from '@/api/gxmu/reviewList'; import {
import type { ReviewList, ReviewListParam } from '@/api/gxmu/reviewList/model'; pageGroupReviewList,
removeBatchReviewList
} from '@/api/gxmu/reviewList';
import type {
ReviewList,
ReviewListParam
} from '@/api/gxmu/reviewList/model';
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null); const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const selection = ref<ReviewList[]>([]); const selection = ref<ReviewList[]>([]);
@@ -79,7 +93,13 @@ const showFlowVisible = ref(false);
const showMove = ref(false); const showMove = ref(false);
const loading = ref(true); const loading = ref(true);
const datasource: DatasourceFunction = ({ page, limit, where, orders, filters }) => { const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) { if (filters) {
where.status = filters.status; where.status = filters.status;
} }
+305
View File
@@ -0,0 +1,305 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1600 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space style="flex-wrap: wrap">
<a-select
v-model:value="collegeId"
allow-clear
show-search
option-filter-prop="label"
style="width: 220px"
placeholder="学院"
:options="collegeOptions"
@change="onCollegeChange"
/>
<a-select
v-model:value="classId"
allow-clear
show-search
option-filter-prop="label"
style="width: 220px"
placeholder="班级"
:options="classOptions"
/>
<a-input
v-model:value="xh"
allow-clear
style="width: 160px"
placeholder="学号"
@pressEnter="reload"
/>
<a-input
v-model:value="xm"
allow-clear
style="width: 140px"
placeholder="姓名"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
style="width: 130px"
placeholder="状态"
:options="statusOptions"
/>
<a-select
v-model:value="claimed"
allow-clear
style="width: 130px"
placeholder="认领状态"
:options="claimedOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
<a-button
v-if="hasPermission('gxmu:student:export')"
:loading="exporting"
@click="doExport"
>
导出
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '在校' : '毕业' }}
</a-tag>
</template>
<template v-else-if="column.key === 'claimed'">
<a-tag :color="record.userId ? 'blue' : 'default'">
{{ record.userId ? '已认领' : '未认领' }}
</a-tag>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { hasPermission } from '@/utils/permission';
import { listOrganizations } from '@/api/system/organization';
import { listClassInfo } from '@/api/gxmu/class';
import { exportStudentRoster, pageStudentRoster } from '@/api/gxmu/student';
import type {
StudentRoster,
StudentRosterParam
} from '@/api/gxmu/student/model';
/** 学院挂载的父节点:广西医科大学 */
const COLLEGE_PARENT_ID = 24;
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const collegeId = ref<number | undefined>(undefined);
const classId = ref<number | undefined>(undefined);
const xh = ref<string>('');
const xm = ref<string>('');
const status = ref<number>(1);
const claimed = ref<boolean | undefined>(undefined);
const exporting = ref(false);
const collegeOptions = ref<{ label: string; value: number }[]>([]);
const classOptions = ref<{ label: string; value: number }[]>([]);
const statusOptions = [
{ label: '在校', value: 1 },
{ label: '毕业', value: 0 }
];
const claimedOptions = [
{ label: '已认领', value: true },
{ label: '未认领', value: false }
];
const columns = ref<ColumnItem[]>([
{ title: '学号', dataIndex: 'xh', key: 'xh', width: 140 },
{ title: '姓名', dataIndex: 'xm', key: 'xm', width: 110 },
{ title: '性别', dataIndex: 'sex', key: 'sex', width: 80 },
{ title: '学院', dataIndex: 'collegeName', key: 'collegeName', width: 200 },
{
title: '班级',
dataIndex: 'className',
key: 'className',
width: 170,
customRender: ({ text }) => text || '-'
},
{ title: '专业', dataIndex: 'zymc', key: 'zymc', width: 170 },
{
title: '培养层次',
dataIndex: 'pyccmc',
key: 'pyccmc',
width: 110
},
{ title: '手机号', dataIndex: 'sjh', key: 'sjh', width: 140 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 90
},
{
title: '认领',
dataIndex: 'userId',
key: 'claimed',
align: 'center',
width: 100
},
{
title: '同步时间',
dataIndex: 'syncTime',
key: 'syncTime',
width: 170,
customRender: ({ text }) => (text ? toDateString(text) : '-')
}
]);
const currentParam = (): StudentRosterParam => ({
collegeId: collegeId.value,
classId: classId.value,
xh: xh.value.trim() || undefined,
xm: xm.value.trim() || undefined,
status: status.value,
claimed: claimed.value
});
const datasource: DatasourceFunction = ({ page, limit }) => {
return pageStudentRoster({ ...currentParam(), page, limit }).catch((e) => {
message.error(e.message);
return { list: [] as StudentRoster[], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const loadColleges = () => {
// 学院取组织树中「广西医科大学」的直接子节点,不再走已废弃的 gxmu_college
listOrganizations({ parentId: COLLEGE_PARENT_ID })
.then((list) => {
collegeOptions.value = list.map((item) => ({
label: item.organizationFullName || item.organizationName || '',
value: item.organizationId || 0
}));
})
.catch((e) => {
message.error(e.message);
});
};
const loadClasses = (id?: number) => {
if (!id) {
classOptions.value = [];
return;
}
listClassInfo({ collegeId: id })
.then((list) => {
classOptions.value = list.map((item) => ({
label: item.className || '',
value: item.id || 0
}));
})
.catch(() => {
classOptions.value = [];
});
};
const onCollegeChange = (value?: number) => {
classId.value = undefined;
loadClasses(value);
};
const resetSearch = () => {
collegeId.value = undefined;
classId.value = undefined;
xh.value = '';
xm.value = '';
status.value = 1;
claimed.value = undefined;
classOptions.value = [];
reload();
};
const doExport = () => {
exporting.value = true;
exportStudentRoster(currentParam())
.then((list) => {
if (!list.length) {
message.warning('没有可导出的数据');
return;
}
const header = [
'学号',
'姓名',
'性别',
'学院',
'班级',
'专业',
'培养层次',
'手机号',
'状态'
];
const lines = list.map((item) =>
[
item.xh,
item.xm,
item.sex,
item.collegeName,
item.className || '',
item.zymc,
item.pyccmc,
item.sjh,
item.status === 1 ? '在校' : '毕业'
]
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
.join(',')
);
const csv = '\uFEFF' + [header.join(','), ...lines].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `学生名册_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
message.success(`已导出 ${list.length} 条`);
})
.catch((e) => {
message.error(e.message || '导出失败');
})
.finally(() => {
exporting.value = false;
});
};
onMounted(() => {
loadColleges();
});
defineExpose({ reload });
</script>
<script lang="ts">
export default {
name: 'StudentRoster'
};
</script>
+243
View File
@@ -0,0 +1,243 @@
<template>
<div class="page">
<div class="ele-body">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:scroll="{ x: 1200 }"
tool-class="ele-toolbar-form"
>
<template #toolbar>
<a-space style="flex-wrap: wrap">
<a-select
v-model:value="collegeId"
allow-clear
show-search
option-filter-prop="label"
style="width: 220px"
placeholder="学院"
:options="collegeOptions"
/>
<a-input
v-model:value="jgh"
allow-clear
style="width: 160px"
placeholder="教工号"
@pressEnter="reload"
/>
<a-input
v-model:value="xm"
allow-clear
style="width: 140px"
placeholder="姓名"
@pressEnter="reload"
/>
<a-select
v-model:value="status"
style="width: 130px"
placeholder="状态"
:options="statusOptions"
/>
<a-button type="primary" @click="reload">查询</a-button>
<a-button @click="resetSearch">重置</a-button>
<a-button
v-if="hasPermission('gxmu:teacher:export')"
:loading="exporting"
@click="doExport"
>
导出
</a-button>
</a-space>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'default'">
{{ record.status === 1 ? '启用' : '停用' }}
</a-tag>
</template>
</template>
</ele-pro-table>
</a-card>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { message } from 'ant-design-vue';
import type { EleProTable } from 'ele-admin-pro';
import { toDateString } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import { hasPermission } from '@/utils/permission';
import { listOrganizations } from '@/api/system/organization';
import { exportTeacherRoster, pageTeacherRoster } from '@/api/gxmu/teacher';
import type {
TeacherRoster,
TeacherRosterParam
} from '@/api/gxmu/teacher/model';
/** 学院挂载的父节点:广西医科大学 */
const COLLEGE_PARENT_ID = 24;
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
const collegeId = ref<number | undefined>(undefined);
const jgh = ref<string>('');
const xm = ref<string>('');
const status = ref<number>(1);
const exporting = ref(false);
const collegeOptions = ref<{ label: string; value: number }[]>([]);
/** 停用=上游已消失,默认不显示(ADR-0004) */
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '停用', value: 0 }
];
const columns = ref<ColumnItem[]>([
{ title: '教工号', dataIndex: 'jgh', key: 'jgh', width: 140 },
{ title: '姓名', dataIndex: 'xm', key: 'xm', width: 110 },
{ title: '性别', dataIndex: 'sex', key: 'sex', width: 80 },
{
title: '学院',
dataIndex: 'collegeName',
key: 'collegeName',
width: 220,
customRender: ({ text }) => text || '-'
},
{
title: '单位号',
dataIndex: 'dwh',
key: 'dwh',
width: 120,
customRender: ({ text }) => text || '-'
},
{
title: '科室教研室编号',
dataIndex: 'ksjybh',
key: 'ksjybh',
width: 160,
customRender: ({ text }) => text || '-'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
align: 'center',
width: 90
},
{
title: '同步时间',
dataIndex: 'syncTime',
key: 'syncTime',
width: 170,
customRender: ({ text }) => (text ? toDateString(text) : '-')
}
]);
const currentParam = (): TeacherRosterParam => ({
collegeId: collegeId.value,
jgh: jgh.value.trim() || undefined,
xm: xm.value.trim() || undefined,
status: status.value
});
const datasource: DatasourceFunction = ({ page, limit }) => {
return pageTeacherRoster({ ...currentParam(), page, limit }).catch((e) => {
message.error(e.message);
return { list: [] as TeacherRoster[], count: 0 };
});
};
const reload = () => {
tableRef.value?.reload();
};
const loadColleges = () => {
// 学院取组织树中「广西医科大学」的直接子节点,与学生名册一致
listOrganizations({ parentId: COLLEGE_PARENT_ID })
.then((list) => {
collegeOptions.value = list.map((item) => ({
label: item.organizationFullName || item.organizationName || '',
value: item.organizationId || 0
}));
})
.catch((e) => {
message.error(e.message);
});
};
const resetSearch = () => {
collegeId.value = undefined;
jgh.value = '';
xm.value = '';
status.value = 1;
reload();
};
const doExport = () => {
exporting.value = true;
exportTeacherRoster(currentParam())
.then((list) => {
if (!list.length) {
message.warning('没有可导出的数据');
return;
}
// 手机号不在导出范围内:后端不返回该字段(spec §5.2)
const header = [
'教工号',
'姓名',
'性别',
'学院',
'单位号',
'科室教研室编号',
'状态'
];
const lines = list.map((item) =>
[
item.jgh,
item.xm,
item.sex,
item.collegeName,
item.dwh || '',
item.ksjybh || '',
item.status === 1 ? '启用' : '停用'
]
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
.join(',')
);
const csv = '\uFEFF' + [header.join(','), ...lines].join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `教师名册_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
message.success(`已导出 ${list.length} 条`);
})
.catch((e) => {
message.error(e.message || '导出失败');
})
.finally(() => {
exporting.value = false;
});
};
onMounted(() => {
loadColleges();
});
defineExpose({ reload });
</script>
<script lang="ts">
export default {
name: 'TeacherRoster'
};
</script>
+118
View File
@@ -0,0 +1,118 @@
export interface AttachmentFile {
name: string;
url: string;
}
const FILE_EXT_PATTERN =
/\.(pdf|docx?|xlsx?|pptx?|csv|txt|zip|rar|7z|jpe?g|png|gif|webp|bmp|svg|mp4|mp3|wav)$/i;
/**
* 从文件地址中取出展示用的文件名
*/
export function getAttachmentName(url?: string) {
if (!url) {
return '未命名文件';
}
const cleanUrl = url.split('?')[0];
const name = cleanUrl.split('/').pop() || cleanUrl;
try {
return decodeURIComponent(name);
} catch (e) {
return name;
}
}
/**
* 判断字符串是否像文件地址(http(s)、协议相对地址、带扩展名的相对路径)
*/
export function isFileUrl(value?: string) {
const text = value?.trim();
if (!text) {
return false;
}
if (/^(https?:)?\/\//i.test(text)) {
return true;
}
return FILE_EXT_PATTERN.test(text) && !/\s/.test(text);
}
export function isImageFile(url?: string) {
const text = (url || '').split('?')[0];
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(text);
}
const normalizeEntry = (item: unknown): AttachmentFile | null => {
if (typeof item === 'string') {
return isFileUrl(item)
? { name: getAttachmentName(item), url: item.trim() }
: null;
}
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>;
const rawUrl = [
record.url,
record.fileUrl,
record.downloadUrl,
record.path
].find((value) => typeof value === 'string' && isFileUrl(value as string));
if (typeof rawUrl !== 'string') {
return null;
}
const rawName = [record.name, record.fileName, record.originalName].find(
(value) => typeof value === 'string' && (value as string).trim()
);
return {
name: (rawName as string | undefined) || getAttachmentName(rawUrl),
url: rawUrl.trim()
};
}
return null;
};
/**
* 把后端存储的附件字段解析成文件列表。
* 兼容:单个地址、JSON 数组字符串、对象数组(name/url、fileName/fileUrl 等)
*/
export function parseAttachments(value: unknown): AttachmentFile[] {
if (value === null || value === undefined || value === '') {
return [];
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) {
return [];
}
if (text.startsWith('[') || text.startsWith('{')) {
try {
return parseAttachments(JSON.parse(text));
} catch (e) {
// 解析失败时按普通地址处理
}
}
const single = normalizeEntry(text);
return single ? [single] : [];
}
if (Array.isArray(value)) {
return value
.map((item) => normalizeEntry(item))
.filter((item): item is AttachmentFile => !!item);
}
const single = normalizeEntry(value);
return single ? [single] : [];
}
/**
* 把附件字段解析结果转换成图片地址列表
*/
export function parseAttachmentImages(value: unknown) {
const urls: string[] = [];
parseAttachments(value).forEach((item) => {
if (isImageFile(item.url)) {
urls.push(item.url);
}
});
if (!urls.length && typeof value === 'string' && isImageFile(value)) {
urls.push(value);
}
return urls;
}
+4 -4
View File
@@ -28,7 +28,7 @@
<div class="dialog-card"> <div class="dialog-card">
<div class="dialog-card__title">开始对话</div> <div class="dialog-card__title">开始对话</div>
<div class="dialog-card__sub">AI+党建的双向赋能</div> <div class="dialog-card__sub">AI+团建的双向赋能</div>
<div class="dialog-card__main">体验全新的AI赋能团委工作</div> <div class="dialog-card__main">体验全新的AI赋能团委工作</div>
<button class="start-btn" @click="goLogin">立即开始</button> <button class="start-btn" @click="goLogin">立即开始</button>
</div> </div>
@@ -63,7 +63,7 @@ const router = useRouter();
const features = [ const features = [
{ {
title: "AI+党建的双向赋能", title: "AI+团建的双向赋能",
desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。" desc: "赋能广西医科大学团委的各项工作。项目遵循“先基础,后智能”的原则,信息化和智能化相结合,确保项目稳步推进,价值逐步释放。"
}, },
{ {
@@ -75,8 +75,8 @@ const features = [
desc: "成为团委老师和学生干部的得力助手,从重复性文案工作中解放生产力。移动端仅提供提供最基础的编辑、审核功能" desc: "成为团委老师和学生干部的得力助手,从重复性文案工作中解放生产力。移动端仅提供提供最基础的编辑、审核功能"
}, },
{ {
title: "创新竞赛历史项目数据库及创新科研课题申报系统", title: "科创竞赛历史项目数据库及创新科研课题申报系统",
desc: "打破信息壁垒,为学生参与创新竞赛提供数据参考;固化校内团委课题申报管理流程,提升管理效率。移动端仅用于查阅信息及简单审核" desc: "打破信息壁垒,为学生参与科创竞赛提供数据参考;固化校内团委课题申报管理流程,提升管理效率。移动端仅用于查阅信息及简单审核"
}, },
{ {
title: "多模态智能宣传矩阵", title: "多模态智能宣传矩阵",
+1 -1
View File
@@ -106,7 +106,7 @@
import { FormInstance } from 'ant-design-vue/es/form'; import { FormInstance } from 'ant-design-vue/es/form';
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting"; import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
import { uploadFile } from '@/api/system/file'; import { uploadFile } from '@/api/system/file';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types'; import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { App } from '@/api/app/model'; import { App } from '@/api/app/model';
import { addApp } from '@/api/app'; import { addApp } from '@/api/app';
import { assignObject } from 'ele-admin-pro'; import { assignObject } from 'ele-admin-pro';
@@ -161,7 +161,7 @@
import type { Merchant } from '@/api/merchant/model'; import type { Merchant } from '@/api/merchant/model';
import { addMerchant, updateMerchant } from '@/api/merchant'; import { addMerchant, updateMerchant } from '@/api/merchant';
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form'; import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types'; import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { uploadFile } from '@/api/system/file'; import { uploadFile } from '@/api/system/file';
import OwnerChoose from '@/components/UserChoose/index.vue'; import OwnerChoose from '@/components/UserChoose/index.vue';
import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting"; import { FILE_SERVER, FILE_THUMBNAIL } from "@/config/setting";
@@ -76,7 +76,7 @@
MerchantClerk, MerchantClerk,
MerchantClerkParam MerchantClerkParam
} from '@/api/merchant/clerk/model'; } from '@/api/merchant/clerk/model';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -162,7 +162,7 @@
import type { Merchant } from '@/api/merchant/model'; import type { Merchant } from '@/api/merchant/model';
import { addMerchant, updateMerchant } from '@/api/merchant'; import { addMerchant, updateMerchant } from '@/api/merchant';
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form'; import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types'; import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { uploadFile } from '@/api/system/file'; import { uploadFile } from '@/api/system/file';
// import OwnerChoose from '@/components/UserChoose/index.vue'; // import OwnerChoose from '@/components/UserChoose/index.vue';
import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting'; import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting';
@@ -101,7 +101,7 @@
updateMerchantWithdraw updateMerchantWithdraw
} from '@/api/merchant/withdraw'; } from '@/api/merchant/withdraw';
import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form'; import { FormInstance, Rule, RuleObject } from 'ant-design-vue/es/form';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types'; import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { uploadFile } from '@/api/system/file'; import { uploadFile } from '@/api/system/file';
import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting'; import { FILE_SERVER, FILE_THUMBNAIL } from '@/config/setting';
import useFormData from '@/utils/use-form-data'; import useFormData from '@/utils/use-form-data';
+1 -1
View File
@@ -517,7 +517,7 @@
import { UploadOutlined } from '@/layout/menu-icons'; import { UploadOutlined } from '@/layout/menu-icons';
import { FormInstance } from 'ant-design-vue/es/form'; import { FormInstance } from 'ant-design-vue/es/form';
import { Company } from '@/api/system/company/model'; import { Company } from '@/api/system/company/model';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types'; import type { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import DictSelect from '@/views/search/components/dict-select.vue'; import DictSelect from '@/views/search/components/dict-select.vue';
import { User } from '@/api/system/user/model'; import { User } from '@/api/system/user/model';
import { uploadFile } from "@/api/system/file"; import { uploadFile } from "@/api/system/file";
@@ -39,7 +39,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
@@ -39,7 +39,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import { import type {
ColumnItem, ColumnItem,
DatasourceFunction DatasourceFunction
} from 'ele-admin-pro/es/ele-pro-table/types'; } from 'ele-admin-pro/es/ele-pro-table/types';
-231
View File
@@ -1,231 +0,0 @@
<!-- 用户编辑弹窗 -->
<template>
<div class="page">
<div class="ele-body" style="padding-bottom: 60px;">
<!-- 表格 -->
<a-spin :spinning="loading">
<table class="ele-table ele-table-stripe ele-table-medium">
<tbody>
<tr v-for="(item,index) in notice" :key="index">
<td style="text-align: right; width: 360px; vertical-align: top;">
<div class="user-avatar">
<a-avatar v-if="item.channel === 0" :src="item.avatar" />
<a-avatar v-else class="openai" />
</div>
</td>
<td>
<div class="content"><byte-md-viewer :value="item.content" ref="md-content" :plugins="plugins" /></div>
</td>
<td>
<a-button v-if="item.channel === 1" @click="copyText(item.content)">复制</a-button>
</td>
</tr>
</tbody>
</table>
</a-spin>
<a-space class="action">
<a-popconfirm
title="确定要删除当前会话吗?"
@confirm="remove(item)"
>
<a-button class="ele-text-danger">清空当前会话</a-button>
</a-popconfirm>
</a-space>
</div>
<a-card class="foot">
<div class="send-box">
<a-space>
<a-input
v-model:value="content"
placeholder="我是AI机器人,你想和我聊点什么?"
size="large"
:disabled="loading"
style="width: 770px"
@pressEnter="onSend"
>
<template #suffix>
<send-outlined @click="onSend" />
</template>
</a-input>
</a-space>
</div>
</a-card>
<a-back-top />
</div>
</template>
<script lang="ts" setup>
import "bytemd/dist/index.min.css";
import { ref } from "vue";
import "github-markdown-css/github-markdown-light.css";
// // 链接、删除线、复选框、表格等的插件
import gfm from "@bytemd/plugin-gfm";
// // 插件的中文语言文件
import zh_HansGfm from "@bytemd/plugin-gfm/locales/zh_Hans.json";
// 中文语言文件
import "bytemd/dist/index.min.css";
import highlight from "@bytemd/plugin-highlight-ssr";
import "highlight.js/styles/default.css";
import { message} from "ant-design-vue";
import { storeToRefs } from "pinia";
import { useThemeStore } from "@/store/modules/theme";
import { uploadFile } from "@/api/system/file";
import type { Notice } from "@/api/oa/notice/model";
import { FILE_SERVER } from "@/config/setting";
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { listNotice, removeNotice } from "@/api/oa/notice";
import { chat, send } from "@/api/oa/chatgpt";
import {
SendOutlined
} from '@ant-design/icons-vue';
import { Message } from "@/api/user/message/model";
import { EquipmentFault } from "@/api/apps/equipment/fault/model";
import { removeEquipmentFault } from "@/api/apps/equipment/fault";
import { EleTableSelect } from "ele-admin-pro";
import { FormInstance } from "ant-design-vue/es/form";
import { copyText, isImage } from "@/utils/common";
// 插件
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
// 请求状态
const loading = ref(false);
const noticeId = ref(0);
const content = ref('');
const files = ref<ItemType[]>([]);
const notice = ref<Notice[]>([]);
/* 上传事件 */
const uploadHandler = (file: File) => {
const item: ItemType = {
file,
uid: (file as any).uid,
name: file.name
};
if (!file.type.startsWith("image")) {
message.error("只能选择图片");
return;
}
if (file.size / 1024 / 1024 > 2) {
message.error("大小不能超过 2MB");
return;
}
onUpload(item);
};
const onUpload = (d: ItemType) => {
uploadFile(<File>d.file)
.then((result) => {
files.value.push({
uid: result.id,
url: FILE_SERVER + result.path,
name: isImage(result.path) ? 'image' : result.name,
status: "done"
});
message.success("上传成功");
})
.catch((e) => {
message.error(e.message);
});
};
/* 向chatGPT提问 */
const onSend = () => {
send({
tenantId: Number(localStorage.getItem('tenantId')),
content: content.value
}).then((noticeId) => {
query();
onChat(noticeId);
})
}
const onChat = (noticeId) => {
chat({
tenantId: Number(localStorage.getItem('tenantId')),
content: content.value,
noticeId
}).then(() => {
query();
content.value = '';
})
}
/* 删除单个 */
const remove = (row: Notice) => {
console.log(row);
const hide = message.loading('请求中..', 0);
removeNotice(row.noticeId)
.then((msg) => {
hide();
message.success(msg);
query();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
const query = () => {
listNotice({limit: 2})
.then((response) => {
notice.value = response
})
.catch((e) => {
message.error(e.message);
});
};
query();
</script>
<script lang="ts">
export default {
name: "ChatGPT"
};
</script>
<style lang="less" scoped>
.content {
max-width: 1000px;
padding: 20px 0;
.edit-md {
margin-top: 50px;
}
}
.foot{
position: fixed;
width: 100%;
background-color: #f3f9ff;
bottom: 0;
height: 100px;
//display: flex;
//justify-content: center;
.send-box{
margin-left: 360px;
}
}
.user-avatar{
padding: 20px 0;
}
.openai{
background-image: url("@/assets/icon/openai-avatar.jpeg");
background-size: 100%;
}
.markdown-body{
background: none;
}
.action{
padding: 20px 0;
display: flex;
justify-content: flex-end;
}
.copy-btn{display: flex; justify-content: flex-end;}
</style>
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
/**
* 「AI 写作助手」的可选项定义(2026-10-21 改造后)。
*
* 本文件与 `tuanwei-uniapp/src/pages/assistant/writing-params.js` 保持同一套常量,
* 改动其一请同步另一侧。
*
* 改造前这里还负责把用户原话解析成「键:值」入参,再据此填 doc_type / Number。
* 现在写作模式只有两条路,都不解析自然语言:
*
* 1. 自由创作(value = `auto`):原话整段作为 `userContent`,后端见 `docType` 为空
* 就转「AI 创意助手」(只吃 `user_content`);
* 2. 选了文体:原话整段作为 `highlights`(核心亮点与具体要求),连同 doc_type
* 交给「内容创作与宣传助手」,字数只认界面上的显式选择。
*
* 为什么不猜:上游提示词里 doc_type / Number 是**主控指令**,模型会显式服从、
* 甚至压掉用户原话里的冲突要求(实测思维链:「I will ignore the conflicting
* "300字推文" instruction … because the main task says "文稿类型为:新闻稿"」)。
* 从原话里推断还会在短祈使句上产出「帮我写个」这类残片当活动主题
* (见 issue 13 的「2026-10-19 契约修正」)。
*
* 本模块只剩选项常量:纯数据、零依赖。
*/
/** 「自由创作」的取值,同时也是 `writingDocType` 的默认值 */
export const WRITING_DOC_TYPE_FREE = 'auto';
/** 上游 Dify 提示词里真正定义了 Markdown 结构与文风的 4 种文体 */
export const WRITING_DOC_TYPE_OPTIONS = [
{ label: '自由创作', value: WRITING_DOC_TYPE_FREE },
{ label: '新闻稿', value: '新闻稿' },
{ label: '活动总结', value: '活动总结' },
{ label: '微信推文', value: '微信推文' },
{ label: '通知公告', value: '通知公告' }
];
/** 字数只在选了文体之后才显示;`auto` 表示不下发 */
export const WRITING_WORD_COUNT_OPTIONS = [
{ label: '默认', value: 'auto' },
{ label: '300字', value: '300' },
{ label: '500字', value: '500' },
{ label: '800字', value: '800' },
{ label: '1000字', value: '1000' },
{ label: '1500字', value: '1500' }
];
/**
* 活动类型(上游 `event_type`)的候选值。
*
* 上游开始节点把 event_type 声明成普通 text-input,没有枚举约束,这里给一组常用值
* 只是为了让用户不用手打;与两个「写作表单页」的取值保持一致
* (tuanwei-uniapp/src/pages/manuscript 与 tuanwei-admin/src/views/gxmu/manuscriptGen)。
*/
export const WRITING_EVENT_TYPE_OPTIONS = [
'校园活动',
'志愿服务',
'会议论坛',
'社会实践',
'文体活动',
'品牌项目'
];
@@ -134,7 +134,7 @@
class="ele-btn-icon" class="ele-btn-icon"
@click="openUrl('https://p.haoii123.com/')" @click="openUrl('https://p.haoii123.com/')"
> >
一键排版 公众号排版
</a-button> </a-button>
</a-space> </a-space>
</div> </div>
+1 -1
View File
@@ -135,7 +135,7 @@ import zh_Hans from 'bytemd/locales/zh_Hans.json';
import gfm from '@bytemd/plugin-gfm'; import gfm from '@bytemd/plugin-gfm';
// 插件的中文语言文件 // 插件的中文语言文件
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json'; import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types"; import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { UploadOutlined } from '@ant-design/icons-vue'; import { UploadOutlined } from '@ant-design/icons-vue';
import { User } from "@/api/system/user/model"; import { User } from "@/api/system/user/model";
import { Company } from "@/api/system/company/model"; import { Company } from "@/api/system/company/model";
+1 -1
View File
@@ -298,7 +298,7 @@ import { FILE_SERVER } from "@/config/setting";
import { getDictionaryOptions, isImage, openUrl } from "@/utils/common"; import { getDictionaryOptions, isImage, openUrl } from "@/utils/common";
import { TaskRecord } from "@/api/oa/task-record/model"; import { TaskRecord } from "@/api/oa/task-record/model";
import { App } from "@/api/app/model" import { App } from "@/api/app/model"
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types"; import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record"; import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record";
import SelectStaff from '@/components/SelectStaff/index.vue'; import SelectStaff from '@/components/SelectStaff/index.vue';
import { UploadOutlined } from '@ant-design/icons-vue'; import { UploadOutlined } from '@ant-design/icons-vue';
@@ -135,7 +135,7 @@ import zh_Hans from 'bytemd/locales/zh_Hans.json';
import gfm from '@bytemd/plugin-gfm'; import gfm from '@bytemd/plugin-gfm';
// 插件的中文语言文件 // 插件的中文语言文件
import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json'; import zh_HansGfm from '@bytemd/plugin-gfm/locales/zh_Hans.json';
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types"; import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { UploadOutlined } from '@ant-design/icons-vue'; import { UploadOutlined } from '@ant-design/icons-vue';
import { User } from "@/api/system/user/model"; import { User } from "@/api/system/user/model";
import { Company } from "@/api/system/company/model"; import { Company } from "@/api/system/company/model";
@@ -298,7 +298,7 @@ import { FILE_SERVER } from "@/config/setting";
import { getDictionaryOptions, isImage, openUrl } from "@/utils/common"; import { getDictionaryOptions, isImage, openUrl } from "@/utils/common";
import { TaskRecord } from "@/api/oa/task-record/model"; import { TaskRecord } from "@/api/oa/task-record/model";
import { App } from "@/api/app/model" import { App } from "@/api/app/model"
import { ItemType } from "ele-admin-pro/es/ele-image-upload/types"; import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record"; import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record";
import SelectStaff from '@/components/SelectStaff/index.vue'; import SelectStaff from '@/components/SelectStaff/index.vue';
import { UploadOutlined } from '@ant-design/icons-vue'; import { UploadOutlined } from '@ant-design/icons-vue';
+51 -91
View File
@@ -26,6 +26,13 @@
> >
账号登录 账号登录
</button> </button>
<button
type="button"
:class="['login-tab', { active: loginType === 'sso' }]"
@click="onLoginType('sso')"
>
统一身份认证
</button>
</div> </div>
<template v-if="loginType === 'sms'"> <template v-if="loginType === 'sms'">
@@ -60,7 +67,7 @@
</a-form-item> </a-form-item>
</template> </template>
<template v-else> <template v-else-if="loginType === 'account'">
<a-form-item v-bind="validateInfos.username"> <a-form-item v-bind="validateInfos.username">
<a-input <a-input
v-model:value="form.username" v-model:value="form.username"
@@ -83,7 +90,33 @@
</a-form-item> </a-form-item>
</template> </template>
<div class="login-agree"> <template v-else>
<a-form-item v-bind="validateInfos.username">
<a-input
v-model:value="form.username"
allow-clear
size="large"
placeholder="请输入学号"
/>
</a-form-item>
<a-form-item v-bind="validateInfos.password">
<div class="login-input-group login-input-plain">
<a-input-password
v-model:value="form.password"
allow-clear
size="large"
placeholder="请输入统一身份认证密码"
@pressEnter="submit"
/>
</div>
</a-form-item>
</template>
<div v-if="loginType === 'sso'" class="login-agree">
使用学校统一身份认证账号(学号)登录,密码由学校认证平台校验,本系统不留存。
</div>
<div v-else class="login-agree">
注册登录即代表已阅读并同意我们的 注册登录即代表已阅读并同意我们的
<span class="link">用户协议</span> <span class="link">用户协议</span>
与 与
@@ -102,19 +135,7 @@
{{ loading ? "登录中" : "登录" }} {{ loading ? "登录中" : "登录" }}
</a-button> </a-button>
</a-form-item> </a-form-item>
<div class="login-divider"></div>
</a-form> </a-form>
<div class="login-qr-card">
<div class="qr-frame">
<div class="qr-placeholder"></div>
</div>
<div class="qr-text">
<img class="wechat-icon" src="@/assets/icon/weixin.png" alt="微信" />
微信扫码登录
</div>
</div>
</div> </div>
</div> </div>
@@ -132,7 +153,7 @@ import { computed, onBeforeUnmount, reactive, ref, unref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Form, message } from "ant-design-vue"; import { Form, message } from "ant-design-vue";
import { cleanPageTabs } from "@/utils/page-tab-util"; import { cleanPageTabs } from "@/utils/page-tab-util";
import { login, sendSmsCaptcha } from "@/api/passport/login"; import { login, loginByGxmuSso, sendSmsCaptcha } from "@/api/passport/login";
import { User } from "@/api/system/user/model"; import { User } from "@/api/system/user/model";
import { THEME_STORE_NAME } from "@/config/setting"; import { THEME_STORE_NAME } from "@/config/setting";
@@ -144,7 +165,7 @@ const sending = ref(false);
const countdown = ref(0); const countdown = ref(0);
let countdownTimer: number | null = null; let countdownTimer: number | null = null;
const loginType = ref<"sms" | "account">("sms"); const loginType = ref<"sms" | "account" | "sso">("sms");
const form = reactive<User & { code?: string }>({ const form = reactive<User & { code?: string }>({
username: "", username: "",
@@ -160,19 +181,25 @@ const toHome = () => {
}; };
const rules = computed(() => { const rules = computed(() => {
if (loginType.value === "account") { if (loginType.value === "account" || loginType.value === "sso") {
return { return {
username: [ username: [
{ {
required: true, required: true,
message: "请输入账号", message:
loginType.value === "sso"
? "请输入学号"
: "请输入账号",
type: "string" type: "string"
} }
], ],
password: [ password: [
{ {
required: true, required: true,
message: "请输入密码", message:
loginType.value === "sso"
? "请输入统一身份认证密码"
: "请输入密码",
type: "string" type: "string"
} }
] ]
@@ -216,7 +243,7 @@ const startCountdown = () => {
}, 1000); }, 1000);
}; };
const onLoginType = (type: "sms" | "account") => { const onLoginType = (type: "sms" | "account" | "sso") => {
loginType.value = type; loginType.value = type;
}; };
@@ -262,7 +289,9 @@ const submit = () => {
if (query.tenantId) { if (query.tenantId) {
form.tenantId = Number(query.tenantId); form.tenantId = Number(query.tenantId);
} }
login(form) const request =
loginType.value === "sso" ? loginByGxmuSso(form) : login(form);
request
.then((msg) => { .then((msg) => {
message.success(msg); message.success(msg);
loading.value = false; loading.value = false;
@@ -338,8 +367,7 @@ onBeforeUnmount(() => {
.login-panel { .login-panel {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr);
gap: 28px;
align-items: start; align-items: start;
justify-content: center; justify-content: center;
justify-items: center; justify-items: center;
@@ -521,60 +549,6 @@ onBeforeUnmount(() => {
font-weight: 700; font-weight: 700;
} }
.login-divider {
margin-top: 16px;
height: 1px;
background: linear-gradient(90deg, transparent, #e6e6e6 20%, #e6e6e6 80%, transparent);
}
.login-qr-card {
width: 100%;
max-width: 320px;
background: #fff;
border-radius: 16px;
border: 1px solid #ececec;
padding: 22px 20px 18px;
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.08);
display: grid;
justify-items: center;
gap: 14px;
}
.qr-frame {
width: 210px;
height: 210px;
padding: 14px;
border-radius: 14px;
border: 1px solid #e6e6e6;
background: #fff;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.08);
display: grid;
place-items: center;
}
.qr-placeholder {
width: 100%;
height: 100%;
border-radius: 8px;
background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.1) 50%, transparent 50%),
linear-gradient(rgba(0, 0, 0, 0.1) 50%, transparent 50%);
background-size: 14px 14px;
background-position: 0 0, 7px 7px;
}
.qr-text {
display: flex;
align-items: center;
gap: 8px;
color: #3e3e3e;
font-weight: 600;
}
.wechat-icon {
width: 20px;
height: 20px;
}
.login-copyright { .login-copyright {
margin-top: 40px; margin-top: 40px;
font-size: 12px; font-size: 12px;
@@ -583,19 +557,10 @@ onBeforeUnmount(() => {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.login-panel {
grid-template-columns: 1fr;
justify-items: center;
}
.login-form { .login-form {
width: 100%; width: 100%;
max-width: 520px; max-width: 520px;
} }
.login-qr-card {
max-width: 360px;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -634,10 +599,5 @@ onBeforeUnmount(() => {
height: 50px; height: 50px;
font-size: 16px; font-size: 16px;
} }
.qr-frame {
width: 180px;
height: 180px;
}
} }
</style> </style>
@@ -148,7 +148,7 @@ import iconData from 'ele-admin-pro/es/ele-icon-picker/icons';
import {assignObject, isExternalLink} from 'ele-admin-pro'; import {assignObject, isExternalLink} from 'ele-admin-pro';
import {addApp, updateApp} from '@/api/dashboard/appstore'; import {addApp, updateApp} from '@/api/dashboard/appstore';
import type {App} from '@/api/dashboard/appstore/model'; import type {App} from '@/api/dashboard/appstore/model';
import {ItemType} from "ele-admin-pro/es/ele-image-upload/types"; import type {ItemType} from "ele-admin-pro/es/ele-image-upload/types";
import {uploadFile} from "@/api/system/file"; import {uploadFile} from "@/api/system/file";
import {getDictionaryOptions} from "@/utils/common"; import {getDictionaryOptions} from "@/utils/common";
@@ -148,7 +148,7 @@ import iconData from 'ele-admin-pro/es/ele-icon-picker/icons';
import {assignObject, isExternalLink} from 'ele-admin-pro'; import {assignObject, isExternalLink} from 'ele-admin-pro';
import {addApp, updateApp} from '@/api/dashboard/appstore'; import {addApp, updateApp} from '@/api/dashboard/appstore';
import type {App} from '@/api/dashboard/appstore/model'; import type {App} from '@/api/dashboard/appstore/model';
import {ItemType} from "ele-admin-pro/es/ele-image-upload/types"; import type {ItemType} from "ele-admin-pro/es/ele-image-upload/types";
import {uploadFile} from "@/api/system/file"; import {uploadFile} from "@/api/system/file";
import {getDictionaryOptions} from "@/utils/common"; import {getDictionaryOptions} from "@/utils/common";

Some files were not shown because too many files have changed in this diff Show More