3 Commits

Author SHA1 Message Date
乘风
9a272c92d8 特性:更新API基础URL并增强标签导航
- 更改了app.js中的API基础URL,以指向用于部署的生产服务器。
- 改进了自定义标签栏功能,可根据审计模式有条件地显示标签。
- 增强了比赛页面逻辑,以处理审核模式检查并动态更新选项卡选择。
- 对我的页面进行了重构,以确保根据当前页面正确选择标签。
此次更新旨在确保API连接正常,并改善用户在应用程序中的导航体验。
2026-05-11 17:23:09 +08:00
乘风
4a105204cc 功能:通过人工智能辅助和用户界面改进,增强超级文章编辑器的功能
- 集成人工智能写作辅助功能,用户可在编辑器中输入提示并直接生成内容。
- 更新了用户界面组件,以提升用户体验,包括新增了一个用于发布动态的浮动操作按钮,以及改进了图片上传的布局。
- 重构了内容处理方式,以支持光标管理和动态内容插入,从而增强了编辑功能。
- 通过更新样式和响应式设计元素,改进了视觉反馈和可访问性。

此次更新旨在简化文章创建流程,并增强超级文章编辑器内的用户互动。
2026-05-11 16:36:54 +08:00
乘风
01f9e216da feat: enhance super article features and UI updates
- Integrated AI writing capabilities in the super article editor, allowing users to upload multiple reference files and generate drafts using prompts.
- Updated the reading page to unify the display of book chapters and super articles, improving user navigation and experience.
- Enhanced the "My" page with new UI elements, including gradient icons and improved layout for better visual appeal.
- Added backend support for new article management endpoints, ensuring seamless integration with the front end.

This update aims to streamline the article creation process and enhance the overall user interface across the application.
2026-05-11 15:43:10 +08:00
36 changed files with 1572 additions and 539 deletions

View File

@@ -52,9 +52,10 @@
| 2026-04-13 | 会议:按功能同步开发文档;页面级记 miniprogram 接口与分享行为;无接口变更亦标注 | 进行中 |
| 2026-04-14 | 吸收沉淀:逆推闭环自检写入 evolutionLOOP 需求行 | 进行中 |
| 2026-04-15 | 业务补齐:`app.js``err.response`;导师详情微信支付;找伙伴 join 失败展示后端文案 | 已完成 |
| 2026-05-09 | 我的页快捷入口:渐变面性 SVG 图标、`stat-icon-wrap` 圆形底托与卡片光效;开发文档运营与变更 / 当前小程序开发细则 §2.2.1 已同步 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-15
**最后更新**2026-05-09

View File

@@ -6,6 +6,10 @@
---
## 2026-05-09
- 文档同步:小程序「我的」快捷入口 UI 变更写入 `开发文档/10、项目管理/运营与变更.md``开发文档/4、前端/当前小程序开发细则.md` §2.2.1`项目索引/小程序.md` 开发进度追加
## 2026-04-15
- 吸收经验(联网):编程思维沉淀至 `agent/团队/evolution/2026-04-15.md`;升级 `role-flow-control` §1.3`经验清单`、总索引、团队/助理橙子项目索引、sync-log 已更新

View File

@@ -77,7 +77,7 @@ description: Soul 创业派对变更关联检查。miniprogram/soul-admin/soul-a
| 找伙伴/匹配 | 匹配、展示 | match、ckb 等 miniprogram 接口 | 匹配配置、开关 |
| 配置项 | 仅读取 | miniprogram/config 或 db/config | 配置编辑admin/db |
| VIP/超级个体 | 展示列表、详情 | miniprogram/vip/membersdb/users PUT、db/vip-roles | 用户列表「设置 VIP」弹窗、VIP 角色管理页 |
| 超级个体发文UGC+AI | `super-article-editor``super-article-detail` | 表 `super_articles``GET/POST` `/api/miniprogram/super/articles*``/super/articles/generate` | 当前无独立超管文章列表为常态时勿只挂 admin**勿**与 book/章节阅读接口混改或合并列表 |
| 超级个体发文UGC+AI | `super-article-editor`阅读页 **`read``superArticleId`**、`super-article-detail`(兼容重定向)、`super-article-mine` | 表 `super_articles``GET/POST/DELETE` `/api/miniprogram/super/articles*``/super/articles/generate` | 当前无独立超管文章列表为常态时勿只挂 admin**勿**与 book/章节阅读接口混改或合并列表;详情 UI 与章节共用 `read`**数据模型仍隔离** |
**专项检查**:若本次变更涉及 `super_articles`、**`super-article-*` 页面** 或 `super/articles` 接口,再读 [super-individual-article/SKILL.md](../super-individual-article/SKILL.md),确认未与书籍/章节、MBTI 的 `soul_articles` 混表或混接口。

View File

@@ -47,7 +47,7 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
- **支付**:下单/查单走 `POST/GET /api/miniprogram/pay`、回调由 soul-api 处理;支付前必须已有 openId通过登录或 getOpenId 获得)。
- **提现**:申请/记录/确认等走 `/api/miniprogram/withdraw/*`;订阅消息模板 ID 在 `app.globalData.withdrawSubscribeTmplId`
- **scene 编解码**:海报与扫码统一用 `utils/scene.js``buildScene`/`parseScene`,支持 mid、id、ref分隔符与后端生成一致`_`)。
- **超级个体 AI 写文章**:数据落在 soul-api 表 `super_articles`,接口为 `/api/miniprogram/super/articles`(生成/发布/列表/详情),页面 `super-article-editor``super-article-detail`**勿与书籍/章节阅读流混为同一数据模型或接口**。详见 [super-individual-article/SKILL.md](../super-individual-article/SKILL.md)。
- **超级个体 AI 写文章**:数据落在 soul-api 表 `super_articles`,接口为 `/api/miniprogram/super/articles`(生成/发布/列表/详情/删除);编辑器 `super-article-editor`**正文详情展示与书籍章节共用 `pages/read/read`,通过 query `superArticleId`(或 `saId`)进入动态模式**`super-article-detail` 仅保留对 `read` 的兼容重定向。**数据模型与书籍章节接口仍分离**,勿混表混列表。详见 [super-individual-article/SKILL.md](../super-individual-article/SKILL.md)。
---
@@ -115,6 +115,6 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
- 做个人中心、设置页布局时(遵循 §7卡片区边距 16rpx
- 做阅读、文章等需长按复制的文本时(遵循 §9text 加 user-select
- 做编辑资料页分享名片时(遵循 §10
- 做超级个体 **AI 写文章**`super-article-editor` / `super-article-detail` 时(遵循 §4 最后一条与 [super-individual-article/SKILL.md](../super-individual-article/SKILL.md))。
- 做超级个体 **AI 写文章**`super-article-editor`、**`read`(含 `superArticleId` 动态详情)** / `super-article-detail`(兼容跳转)时(遵循 §4 超级个体一条与 [super-individual-article/SKILL.md](../super-individual-article/SKILL.md))。
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。

View File

@@ -44,7 +44,7 @@ description: 超级个体 AI 写文章。super_articles 表、/api/miniprogram/s
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/miniprogram/super/articles/generate` | 根据描述 + 可选图片 URL 调用大模型,返回 `title`/`content` 草稿;需配置 OpenAI 兼容 Key/模型 |
| POST | `/api/miniprogram/super/articles/generate` | 提示词 `description` + 可选 `referenceText`(端上文本文件拼接)+ `materialUrls`(附件上传 URL服务端安全域内抓取+ `imageUrls`;四者至少一项;返回 `title`/`content` 草稿;需 OpenAI 兼容 Key/模型;契约见 `开发文档/5、接口/API接口完整文档.md` §1.3 |
| POST | `/api/miniprogram/super/articles` | 发布body`userId`, `title`, `content`;失败时可能带 `forbidden: true` |
| GET | `/api/miniprogram/super/articles?authorUserId=&page=&pageSize=` | 作者为超级个体时返回 ta 的文章列表(含 preview |
| GET | `/api/miniprogram/super/articles/:id` | 详情(作者须为超级个体,否则不可见) |
@@ -57,11 +57,14 @@ description: 超级个体 AI 写文章。super_articles 表、/api/miniprogram/s
| 页面/逻辑 | 路径 |
|-----------|------|
| AI 写文 + 发布 | [miniprogram/pages/super-article-editor/](miniprogram/pages/super-article-editor/):生成 `POST .../super/articles/generate`,发布 `POST .../super/articles`图片走 `wx.uploadFile` `/api/upload``formData.folder = article-images` |
| 详情 | [miniprogram/pages/super-article-detail/](miniprogram/pages/super-article-detail/) |
| 入口 | [miniprogram/pages/my/my.js](miniprogram/pages/my/my.js) 中 `superArticle``showSuperArticleEntry`;进编辑器前会调 `GET /api/miniprogram/my/super-stats` 校验 `isSuperIndividual` |
| AI 写文 + 发布 | [miniprogram/pages/super-article-editor/](miniprogram/pages/super-article-editor/)多文件 `chooseMessageFile`;图片上传 `folder=article-images`,附件 `folder=book-attachments`,文本可读类型可本地 read 后并入 `referenceText`生成 `POST .../super/articles/generate`timeout 建议 ≥120s,发布 `POST .../super/articles`统一 `utils/miniprogramUpload.js` `POST /api/miniprogram/upload` |
| **动态详情(正文展示)** | **`pages/read/read`**`onLoad`**`superArticleId`**(或 `saId`)时为超级个体详情模式(顶栏「文章详情」);正文/HTML、配图解析见 [miniprogram/utils/superArticleDetail.js](miniprogram/utils/superArticleDetail.js) |
| 兼容旧链 | [miniprogram/pages/super-article-detail/](miniprogram/pages/super-article-detail/):仅 **`redirectTo`** → `read?superArticleId=` |
| 我的动态列表 | [miniprogram/pages/super-article-mine/](miniprogram/pages/super-article-mine/) |
| 入口 | [miniprogram/pages/my/my.js](miniprogram/pages/my/my.js) 中 **`superArticleMine`**、`showSuperArticleEntry`;进编辑器前会调 `GET /api/miniprogram/my/super-stats` 校验 `isSuperIndividual` |
- 所有请求**必须**走 `app.request`**`/api/miniprogram/...` 前缀**(与 miniprogram-dev 一致)。
- **数据仍独立**`super_articles` 与书籍章节接口不合并;仅小程序 **UI** 共用 `read` 页面容器。
---
@@ -81,6 +84,6 @@ description: 超级个体 AI 写文章。super_articles 表、/api/miniprogram/s
## 7. 何时激活本 Skill
- 改「超级个体」「AI 写文章」「super-article-editor」super-article-detail`super_articles`
- 改「超级个体」「AI 写文章」「super-article-editor」、**阅读页动态模式(`read` + `superArticleId`**、`super-article-detail`(兼容重定向)`super_articles`
- 需区分 **本书章节**、**MBTI 引流文** 与 **超级个体发文** 时。
- 设计草稿/审核/定时发等新能力前,先读本 Skill §2 与 §5避免与现有表职责混淆。

View File

@@ -11,12 +11,12 @@ const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
// 线上正式 API真机/体验版/正式版)。永平仓库默认正式域;部署前核对见 .cursor/skills/security-server-ops/SKILL.md
// const API_BASE_URL_PROD = 'https://soulapi.quwanzhi.com'
const API_BASE_URL_PROD = 'http://127.0.0.1:9100'
const API_BASE_URL_PROD = 'https://soulapi.quwanzhi.com'
// const API_BASE_URL_PROD = 'http://127.0.0.1:9100'
// 开发者工具默认与正式一致;若需连测服可改为 https://souldev.quwanzhi.com 或本机 http://127.0.0.1:9100
// const API_BASE_URL_DEV = 'https://soulapi.quwanzhi.com'
const API_BASE_URL_DEV ='http://127.0.0.1:9100'
const API_BASE_URL_DEV = 'https://soulapi.quwanzhi.com'
// const API_BASE_URL_DEV ='http://127.0.0.1:9100'
// 运行时自动判断:非开发工具(真机/体验版/正式版)强制用 PROD避免 localhost 打进上传包导致白屏
const _isDevTools = (typeof __wxConfig !== 'undefined' && __wxConfig.platform === 'devtools')

View File

@@ -1,6 +1,6 @@
/**
* Soul创业实验 - 自定义 TabBar
* 支持:首页 / 目录 / [找伙伴] / [动态,审核模式隐藏] / 我的
* 支持:首页 / 目录 / [找伙伴,审核模式隐藏] / [动态,审核模式隐藏] / 我的
*/
const app = getApp()
@@ -73,7 +73,7 @@ Component({
isSpecial: false,
},
]
if (matchEnabled) {
if (matchEnabled && !audit) {
tabs.push({
pagePath: '/pages/match/match',
text: ma,
@@ -131,7 +131,8 @@ Component({
if (auditMode) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route === 'pages/super-moments/super-moments') {
const route = currentPage && currentPage.route
if (route === 'pages/super-moments/super-moments' || route === 'pages/match/match') {
wx.switchTab({ url: '/pages/index/index' })
}
}
@@ -147,7 +148,8 @@ Component({
if (auditMode) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route === 'pages/super-moments/super-moments') {
const route = currentPage && currentPage.route
if (route === 'pages/super-moments/super-moments' || route === 'pages/match/match') {
wx.switchTab({ url: '/pages/index/index' })
}
}

View File

@@ -94,9 +94,16 @@ Page({
async onLoad() {
wx.showShareMenu({ withShareTimeline: true })
try {
await app.getAuditMode()
} catch (_) {}
if (app.globalData.auditMode) {
wx.switchTab({ url: '/pages/index/index' })
return
}
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
auditMode: app.globalData.auditMode || false
auditMode: false,
})
await this.loadMatchConfig()
this.loadStoredContact()
@@ -104,13 +111,28 @@ Page({
await this.refreshMatchQuotaFromServer()
},
onShow() {
async onShow() {
try {
await app.getAuditMode()
} catch (_) {}
if (app.globalData.auditMode) {
wx.switchTab({ url: '/pages/index/index' })
return
}
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
if (tabBar.updateSelected) {
tabBar.updateSelected()
} else {
tabBar.setData({ selected: 2 })
const tabList = tabBar.data.tabList || []
let sel = 0
for (let i = 0; i < tabList.length; i++) {
if (tabList[i].pagePath.replace(/^\//, '') === 'pages/match/match') {
sel = i
break
}
}
tabBar.setData({ selected: sel })
}
}
this.initUserStatus()

View File

@@ -131,8 +131,15 @@ Page({
if (tabBar.updateSelected) {
tabBar.updateSelected()
} else {
const selected = tabBar.data.matchEnabled ? 3 : 2
tabBar.setData({ selected })
const tabList = tabBar.data.tabList || []
let sel = 0
for (let i = 0; i < tabList.length; i++) {
if (tabList[i].pagePath.replace(/^\//, '') === 'pages/my/my') {
sel = i
break
}
}
tabBar.setData({ selected: sel })
}
}
this.initUserStatus()

View File

@@ -4,19 +4,15 @@
const app = getApp()
const { uploadByApi } = require('../../utils/miniprogramUpload')
const TEXT_EXTS = ['.txt', '.md', '.json', '.csv', '.log', '.yaml', '.yml']
const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
const MAX_TEXT_READ_BYTES = 1024 * 1024
const MAX_REFERENCE_SEND = 28000
function newAttachId() {
return `f_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
Page({
data: {
/** 临时隐藏「AI 写文章」卡片;需上线时再设为 true */
showAiWriteSection: false,
/** 「AI 帮你写」入口(弹窗内填写提示词并生成) */
showAiWriteSection: true,
aiAssistVisible: false,
statusBarHeight: 44,
auditMode: false,
title: '',
@@ -25,9 +21,10 @@ Page({
saving: false,
meNickname: '',
aiPrompt: '',
attachedFiles: [],
generating: false,
editArticleId: '',
/** 正文光标,用于 @自己 插入 */
contentCursor: 0,
},
/** 遮挡层拦截滚动穿透 */
@@ -35,10 +32,33 @@ Page({
syncAuditMode() {
const audit = !!app.globalData.auditMode
this.setData({ auditMode: audit })
const patch = { auditMode: audit }
if (audit) patch.aiAssistVisible = false
this.setData(patch)
return audit
},
openAiAssist() {
this.syncAuditMode()
if (this.data.auditMode || app.globalData.auditMode) {
wx.showToast({ title: '审核模式下不可用', icon: 'none' })
return
}
if (!this.data.showAiWriteSection) return
this.setData({ aiAssistVisible: true })
},
closeAiAssist() {
if (this.data.generating) {
wx.showToast({ title: '生成中,请稍候…', icon: 'none' })
return
}
this.setData({ aiAssistVisible: false })
},
/** 阻止弹层内容区点击冒泡到遮罩(部分机型兼容) */
noopModalTap() {},
onLoad(options) {
const editId = options && options.id != null ? String(options.id).trim() : ''
if (editId) {
@@ -114,6 +134,7 @@ Page({
title: '',
content: String(article.content || ''),
bodyImages,
contentCursor: String(article.content || '').length,
})
} catch (e) {
wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' })
@@ -130,21 +151,45 @@ Page({
},
onContentInput(e) {
this.setData({ content: e.detail.value || '' })
const v = e.detail.value != null ? String(e.detail.value) : ''
let cursor = e.detail.cursor
if (typeof cursor !== 'number' || Number.isNaN(cursor)) cursor = v.length
cursor = Math.max(0, Math.min(cursor, v.length))
this.setData({ contentCursor: cursor })
},
onContentBlur(e) {
const v = e.detail.value != null ? String(e.detail.value) : ''
let cursor = e.detail.cursor
if (typeof cursor !== 'number' || Number.isNaN(cursor)) cursor = v.length
cursor = Math.max(0, Math.min(cursor, v.length))
this.setData({ contentCursor: cursor })
},
insertAtContentCursor(insert) {
if (this.data.auditMode) return false
const raw = this.data.content != null ? String(this.data.content) : ''
const seg = String(insert || '')
if (!seg) return false
let pos = this.data.contentCursor
if (typeof pos !== 'number' || Number.isNaN(pos)) pos = raw.length
pos = Math.max(0, Math.min(pos, raw.length))
const next = raw.slice(0, pos) + seg + raw.slice(pos)
const newCursor = pos + seg.length
this.setData({
content: next,
contentCursor: newCursor,
})
return true
},
insertMentionSelf() {
if (this.data.auditMode) return
const nick = this.data.meNickname || '我'
this.setData({ content: `${this.data.content}@${nick} ` })
this.insertAtContentCursor(`@${nick} `)
},
insertHashLink() {
if (this.data.auditMode) return
this.setData({ content: `${this.data.content}#链接(https://)` })
},
/** 编辑区配图(发布后展示在正文前),最多 9 张 */
/** 动态配图(发动态展示 + AI 生成多模态共用),最多 9 张 */
chooseArticleImages() {
this.syncAuditMode()
if (this.data.auditMode || app.globalData.auditMode) {
@@ -255,160 +300,11 @@ Page({
})
},
/** 参考素材:仅从相册/相机选图片并上传AI 侧按图理解) */
chooseReferenceImages() {
this.syncAuditMode()
if (this.data.auditMode || app.globalData.auditMode) {
wx.showToast({ title: '审核模式下不可用', icon: 'none' })
return
}
this._pickReferenceImagesFromAlbum()
},
_pickReferenceImagesFromAlbum() {
const mapFromChooseMedia = (res) => {
const files = res.tempFiles || []
return files.map((f, i) => ({
path: f.tempFilePath,
name: `参考图_${Date.now()}_${i}.jpg`,
size: f.size || 0,
}))
}
const mapFromChooseImage = (res) => {
const paths = res.tempFilePaths || []
const t = Date.now()
return paths.map((path, i) => ({
path,
name: `参考图_${t}_${i}.jpg`,
size: 0,
}))
}
if (typeof wx.chooseMedia === 'function') {
wx.chooseMedia({
count: 9,
mediaType: ['image'],
sourceType: ['album', 'camera'],
sizeType: ['compressed'],
success: (res) => this.handlePickedFiles(mapFromChooseMedia(res)),
fail: (err) => {
const em = String((err && err.errMsg) || '')
if (/cancel|取消/i.test(em)) return
wx.showToast({ title: '选择图片失败', icon: 'none' })
},
})
return
}
if (typeof wx.chooseImage === 'function') {
wx.chooseImage({
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => this.handlePickedFiles(mapFromChooseImage(res)),
fail: (err) => {
const em = String((err && err.errMsg) || '')
if (/cancel|取消/i.test(em)) return
wx.showToast({ title: '选择图片失败', icon: 'none' })
},
})
return
}
wx.showToast({ title: '当前环境不支持选图片', icon: 'none' })
},
_pickUploadUrl(up) {
if (!up || typeof up !== 'object') return ''
return String(up.url || up.fullUrl || up.data?.url || up.data?.fullUrl || '').trim()
},
_patchAttach(id, patch) {
const next = this.data.attachedFiles.map((x) =>
x.id === id ? Object.assign({}, x, patch) : x,
)
this.setData({ attachedFiles: next })
},
_removeAttachById(id) {
this.setData({
attachedFiles: this.data.attachedFiles.filter((x) => x.id !== id),
})
},
async handlePickedFiles(tempFiles) {
if (!tempFiles.length) return
for (const f of tempFiles) {
const name = String(f.name || '未命名').trim() || '未命名'
const filePath = f.path
const size = f.size || 0
const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : ''
const id = newAttachId()
if (size > 29 * 1024 * 1024) {
wx.showToast({ title: `${name} 超过30MB上限`, icon: 'none' })
continue
}
if (IMAGE_EXTS.includes(ext)) {
this.setData({
attachedFiles: this.data.attachedFiles.concat([
{ id, name, kind: 'image', url: '', uploading: true },
]),
})
try {
const up = await uploadByApi({ filePath, folder: 'article-images' })
const url = this._pickUploadUrl(up)
if (!url) throw new Error('no url')
this._patchAttach(id, { url, uploading: false })
} catch (_) {
this._removeAttachById(id)
wx.showToast({ title: `${name} 上传失败`, icon: 'none' })
}
continue
}
if (TEXT_EXTS.includes(ext) && size <= MAX_TEXT_READ_BYTES && size >= 0) {
try {
const fs = wx.getFileSystemManager()
const textContent = fs.readFileSync(filePath, 'utf8')
this.setData({
attachedFiles: this.data.attachedFiles.concat([
{ id, name, kind: 'text', textContent, uploading: false },
]),
})
} catch (_) {
await this._uploadBinaryAttach(id, name, filePath)
}
continue
}
await this._uploadBinaryAttach(id, name, filePath)
}
},
async _uploadBinaryAttach(id, name, filePath) {
this.setData({
attachedFiles: this.data.attachedFiles.concat([
{ id, name, kind: 'binary', url: '', uploading: true },
]),
})
try {
const up = await uploadByApi({ filePath, folder: 'book-attachments' })
const url = this._pickUploadUrl(up)
if (!url) throw new Error('no url')
this._patchAttach(id, { url, uploading: false })
} catch (_) {
this._removeAttachById(id)
wx.showToast({ title: `${name} 上传失败`, icon: 'none' })
}
},
removeAttachedFile(e) {
if (this.data.auditMode) return
const id = e.currentTarget.dataset.id
if (!id) return
this._removeAttachById(id)
},
async generateArticle() {
if (this.data.auditMode || app.globalData.auditMode) return
if (this.data.generating) return
@@ -418,34 +314,20 @@ Page({
return
}
if (this.data.attachedFiles.some((x) => x.uploading)) {
wx.showToast({ title: '请等待图上传完成', icon: 'none' })
if (this.data.bodyImages.some((x) => x.uploading)) {
wx.showToast({ title: '请等待图上传完成', icon: 'none' })
return
}
const prompt = String(this.data.aiPrompt || '').trim()
const parts = []
for (const f of this.data.attachedFiles) {
if (f.kind === 'text' && f.textContent) {
parts.push(`--- ${f.name} ---\n${f.textContent}`)
}
}
let referenceText = parts.join('\n\n').trim()
if (referenceText.length > MAX_REFERENCE_SEND) {
referenceText =
referenceText.slice(0, MAX_REFERENCE_SEND) + '\n\n本地参考文末已截断'
}
const referenceText = ''
const materialUrls = []
const imageUrls = this.data.bodyImages
.map((x) => String(x.url || '').trim())
.filter(Boolean)
const materialUrls = this.data.attachedFiles
.filter((x) => x.kind === 'binary' && x.url)
.map((x) => x.url)
const imageUrls = this.data.attachedFiles
.filter((x) => x.kind === 'image' && x.url)
.map((x) => x.url)
if (!prompt && !referenceText && materialUrls.length === 0 && imageUrls.length === 0) {
wx.showToast({ title: '请填写提示词或添加参考图', icon: 'none' })
if (!prompt && imageUrls.length === 0) {
wx.showToast({ title: '请填写提示词或添加配图', icon: 'none' })
return
}
@@ -470,7 +352,13 @@ Page({
if (title && merged) merged = `${title}\n\n${merged}`
else if (title) merged = title
if (!merged.trim()) throw new Error('生成结果不完整')
this.setData({ title: '', content: merged.trim() })
const mergedStr = merged.trim()
this.setData({
title: '',
content: mergedStr,
contentCursor: mergedStr.length,
aiAssistVisible: false,
})
wx.showToast({ title: '已填入正文', icon: 'success' })
} catch (e) {
wx.showToast({
@@ -522,13 +410,13 @@ Page({
})
}
if (!res?.success) throw new Error(res?.error || '发布失败')
if (editId) {
wx.showToast({ title: '已提交审核', icon: 'success' })
setTimeout(() => wx.navigateBack(), 450)
} else {
if (!editId) {
this.setData({ title: '', content: '', bodyImages: [] })
wx.showToast({ title: '已提交审核', icon: 'success' })
}
wx.showToast({ title: '已提交审核', icon: 'success' })
setTimeout(() => {
wx.redirectTo({ url: '/pages/super-article-mine/super-article-mine' })
}, 450)
} catch (e) {
wx.showToast({ title: e.message || '发布失败', icon: 'none' })
} finally {

View File

@@ -8,72 +8,89 @@
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- AI参考图片 + 提示词开关showAiWriteSection -->
<view wx:if="{{showAiWriteSection}}" class="card">
<text class="section-title">AI 帮你写</text>
<text class="label">参考图片(可多选)</text>
<view class="file-actions">
<view class="add-file-btn" bindtap="chooseReferenceImages"> 添加图片</view>
</view>
<view wx:for="{{attachedFiles}}" wx:key="id" class="file-row">
<view class="file-main">
<text class="file-name">{{item.name}}</text>
<text wx:if="{{item.uploading}}" class="file-meta">上传中…</text>
<text wx:elif="{{item.kind === 'text'}}" class="file-meta ok">文本已载入</text>
<text wx:elif="{{item.kind === 'image'}}" class="file-meta ok">图片</text>
<text wx:else class="file-meta ok">附件</text>
<!-- 主编曲面:配图 → 正文(快捷插入含 AI点开底部弹层 -->
<view class="composer-sheet">
<view class="sheet-section">
<view class="section-head">
<text class="section-title-text">配图</text>
<text class="section-badge">{{bodyImages.length}}/9</text>
</view>
<text class="file-remove" data-id="{{item.id}}" bindtap="removeAttachedFile">移除</text>
</view>
<text class="hint">从相册或相机添加参考图,可多选(单次最多 9 张),上传后参与 AI 生成。单张建议不超 30MB。</text>
<text class="label">提示词</text>
<view class="textarea-wrap">
<textarea class="textarea prompt-textarea" maxlength="2000" placeholder="描述主题、语气、要点…(可与参考图组合)" value="{{aiPrompt}}" bindinput="onAiPromptInput" disabled="{{auditMode}}"></textarea>
<text class="section-desc">可选 · 弹窗内「AI 帮你写」会与下方配图共用 · 发布后展示在正文下方</text>
<view class="moment-photo-grid">
<view wx:for="{{bodyImages}}" wx:key="id" class="moment-photo-cell">
<image wx:if="{{item.url}}" class="moment-photo-img" mode="aspectFill" src="{{item.url}}" />
<view wx:if="{{item.uploading}}" class="moment-photo-mask">上传中…</view>
<text class="moment-photo-remove" data-id="{{item.id}}" catchtap="removeArticleImage">×</text>
</view>
<view wx:if="{{bodyImages.length < 9}}" class="moment-photo-cell moment-photo-add" bindtap="chooseArticleImages">
<text class="moment-photo-add-plus">+</text>
</view>
</view>
<text class="hint-micro">单张建议不超 30MB</text>
</view>
<view class="generate-btn {{generating ? 'generate-btn-loading' : ''}}" bindtap="generateArticle">{{generating ? '生成中…' : '生成'}}</view>
<view class="section-divider"></view>
<view class="sheet-section sheet-section--body">
<view class="section-head">
<text class="section-title-text">正文</text>
<text class="section-meta">最多 5000 字</text>
</view>
<view class="inline-toolbar">
<text class="toolbar-label">快捷插入</text>
<view class="tool-btn" hover-class="tool-btn-hover" catchtap="insertMentionSelf">@自己</view>
<view wx:if="{{showAiWriteSection && !auditMode}}" class="tool-btn" hover-class="tool-btn-hover" catchtap="openAiAssist">✨ AI 帮你写</view>
</view>
<view class="textarea-wrap moment-textarea-wrap">
<textarea
class="textarea moment-textarea"
maxlength="5000"
placeholder="写点什么…支持 Markdown、多段落"
model:value="{{content}}"
bindinput="onContentInput"
bindblur="onContentBlur"
cursor="{{contentCursor}}"
disabled="{{auditMode}}"
auto-height
show-confirm-bar="{{false}}"
></textarea>
</view>
</view>
</view>
<!-- 朋友圈式:先文字,再九宫格配图(+ 在最后) -->
<view class="card moment-card">
<view class="toolbar">
<view class="tool-btn" bindtap="insertMentionSelf">@自己</view>
<view class="tool-btn" bindtap="insertHashLink">#链接</view>
</view>
<view class="textarea-wrap moment-textarea-wrap">
<textarea
class="textarea moment-textarea"
maxlength="5000"
placeholder="这一刻的想法…"
value="{{content}}"
bindinput="onContentInput"
disabled="{{auditMode}}"
auto-height
show-confirm-bar="{{false}}"
></textarea>
</view>
<view class="moment-photo-grid">
<view wx:for="{{bodyImages}}" wx:key="id" class="moment-photo-cell">
<image wx:if="{{item.url}}" class="moment-photo-img" mode="aspectFill" src="{{item.url}}" />
<view wx:if="{{item.uploading}}" class="moment-photo-mask">上传中…</view>
<text class="moment-photo-remove" data-id="{{item.id}}" catchtap="removeArticleImage">×</text>
</view>
<view wx:if="{{bodyImages.length < 9}}" class="moment-photo-cell moment-photo-add" bindtap="chooseArticleImages">
<text class="moment-photo-add-plus">+</text>
</view>
</view>
<text class="hint moment-hint">配图 {{bodyImages.length}}/9 · 展示在文字下方 · 单张建议不超 30MB · 可直接输入 @昵称 或 #链接(https://xxx)</text>
</view>
<!-- 底部发布(避让微信胶囊) -->
<view wx:if="{{!auditMode}}" class="bottom-publish-bar">
<view class="bottom-publish-btn {{saving ? 'bottom-publish-btn-disabled' : ''}}" bindtap="submitArticle">
<text>{{saving ? (editArticleId ? '提交中…' : '发布中…') : (editArticleId ? '提交审核' : '发布')}}</text>
</view>
</view>
<!-- AI 底部弹层 -->
<view wx:if="{{aiAssistVisible}}" class="ai-modal-root" catchtouchmove="preventMove">
<view class="ai-modal-mask" catchtap="closeAiAssist"></view>
<view class="ai-modal-panel" catchtap="noopModalTap">
<view class="ai-modal-head">
<text class="ai-modal-title">AI 帮你写</text>
<text class="ai-modal-close" catchtap="closeAiAssist">关闭</text>
</view>
<text class="ai-modal-desc">提示词 + 当前配图一并交给模型;生成后写入正文,可关闭弹窗继续编辑</text>
<text class="label-ai">提示词</text>
<view class="textarea-wrap textarea-wrap-ai">
<textarea
class="textarea prompt-textarea-inner"
maxlength="2000"
placeholder="例如:根据配图写一篇派对复盘…"
value="{{aiPrompt}}"
bindinput="onAiPromptInput"
disabled="{{auditMode}}"
show-confirm-bar="{{false}}"
></textarea>
</view>
<view class="generate-btn {{generating ? 'generate-btn-loading' : ''}}" bindtap="generateArticle">
{{generating ? '生成中…' : '生成并填入正文'}}
</view>
</view>
</view>
<view wx:if="{{auditMode}}" class="audit-mode-mask" catchtouchmove="preventMove">
<text class="audit-mode-text">开发中...</text>
</view>

View File

@@ -1,248 +1,790 @@
.page {
min-height: 100vh;
background: #0b1220;
background: linear-gradient(180deg, #080f18 0%, #0b1220 32%, #0b1220 100%);
color: #fff;
box-sizing: border-box;
padding-bottom: calc(168rpx + env(safe-area-inset-bottom));
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
.nav-bar {
position: fixed; left: 0; right: 0; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
height: 44px; padding: 0 24rpx; background: rgba(5, 11, 20, 0.9);
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 24rpx;
background: rgba(5, 11, 20, 0.92);
border-bottom: 1rpx solid rgba(94, 234, 212, 0.08);
}
.nav-back { width: 64rpx; flex-shrink: 0; display: flex; align-items: center; }
.nav-placeholder { width: 64rpx; flex-shrink: 0; }
.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 700; }
.nav-back {
width: 64rpx;
flex-shrink: 0;
display: flex;
align-items: center;
}
.nav-placeholder {
width: 64rpx;
flex-shrink: 0;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 32rpx;
font-weight: 700;
}
/* ========== 主编曲面 ========== */
.composer-sheet {
margin: 16rpx 24rpx 24rpx;
border-radius: 28rpx;
overflow: hidden;
background: rgba(15, 23, 42, 0.92);
border: 1rpx solid rgba(148, 163, 184, 0.22);
box-shadow: 0 24rpx 48rpx rgba(0, 0, 0, 0.35);
}
.sheet-section {
padding: 28rpx 28rpx 26rpx;
}
.sheet-section--body {
padding-top: 24rpx;
padding-bottom: 28rpx;
}
.sheet-section--ai {
padding-top: 24rpx;
padding-bottom: 30rpx;
background: linear-gradient(
180deg,
rgba(8, 47, 73, 0.22) 0%,
rgba(15, 23, 42, 0.05) 100%
);
}
.section-divider {
height: 1rpx;
margin: 0 28rpx;
background: rgba(148, 163, 184, 0.14);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 12rpx;
}
.section-title-text {
font-size: 30rpx;
font-weight: 700;
color: #f1f5f9;
letter-spacing: 1rpx;
}
.section-badge {
font-size: 22rpx;
font-weight: 600;
color: #22d3ee;
padding: 6rpx 16rpx;
border-radius: 999rpx;
background: rgba(34, 211, 238, 0.12);
border: 1rpx solid rgba(34, 211, 238, 0.28);
}
.section-meta {
font-size: 22rpx;
color: #94a3b8;
}
.section-tag-ai {
font-size: 22rpx;
color: #a5f3fc;
padding: 4rpx 14rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(94, 234, 212, 0.35);
background: rgba(34, 211, 238, 0.08);
}
.section-desc {
display: block;
font-size: 22rpx;
color: #94a3b8;
line-height: 1.55;
margin-bottom: 16rpx;
}
.hint-micro {
display: block;
margin-top: 14rpx;
font-size: 20rpx;
color: #64748b;
}
.inline-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 14rpx;
}
.toolbar-label {
font-size: 22rpx;
color: #64748b;
margin-right: 4rpx;
}
.tool-btn {
padding: 12rpx 22rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 600;
color: #67e8f9;
border: 1rpx solid rgba(34, 211, 238, 0.4);
background: rgba(8, 47, 73, 0.5);
}
.tool-btn-hover {
opacity: 0.82;
}
.label-ai {
display: block;
font-size: 24rpx;
color: #cbd5e1;
margin-bottom: 10rpx;
margin-top: 4rpx;
}
.textarea-wrap,
.textarea-wrap-ai {
background: rgba(2, 6, 23, 0.78);
border-radius: 18rpx;
padding: 18rpx 20rpx;
border: 1rpx solid rgba(148, 163, 184, 0.12);
}
.textarea-wrap-ai {
margin-bottom: 16rpx;
}
.moment-textarea-wrap {
padding: 20rpx 22rpx;
}
.textarea {
width: 100%;
min-height: 320rpx;
color: #fff;
font-size: 30rpx;
line-height: 1.65;
background: transparent;
}
.moment-textarea {
min-height: 280rpx;
}
.prompt-textarea-inner {
min-height: 160rpx;
width: 100%;
font-size: 28rpx;
line-height: 1.55;
color: #f1f5f9;
background: transparent;
}
.generate-btn {
margin-top: 8rpx;
height: 88rpx;
border-radius: 18rpx;
border: 1rpx solid rgba(34, 211, 238, 0.5);
color: #ecfeff;
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(8, 47, 73, 0.85), rgba(15, 118, 110, 0.35));
}
.generate-btn-loading {
opacity: 0.65;
pointer-events: none;
}
.bottom-publish-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 20rpx 28rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: rgba(11, 18, 32, 0.96);
border-top: 1rpx solid rgba(94, 234, 212, 0.12);
}
.bottom-publish-btn {
height: 92rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
color: #032b35;
font-size: 32rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.bottom-publish-btn-disabled {
opacity: 0.65;
pointer-events: none;
}
.card {
margin: 24rpx; padding: 28rpx; border-radius: 24rpx;
background: rgba(15, 23, 42, 0.86); border: 1rpx solid rgba(148, 163, 184, 0.2);
}
.section-title {
display: block; font-size: 26rpx; font-weight: 700; color: #e2e8f0;
margin-bottom: 20rpx; letter-spacing: 2rpx;
}
.label { display: block; font-size: 24rpx; color: #cbd5e1; margin-bottom: 12rpx; }
.input-wrap, .textarea-wrap { background: rgba(2, 6, 23, 0.75); border-radius: 16rpx; padding: 18rpx 20rpx; }
.input { width: 100%; color: #fff; font-size: 28rpx; background: transparent; }
.file-actions { margin-bottom: 12rpx; }
.add-file-btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 14rpx 28rpx; border-radius: 16rpx; font-size: 26rpx; font-weight: 600;
color: #22d3ee; border: 1rpx solid rgba(34, 211, 238, 0.45);
background: rgba(8, 47, 73, 0.45);
}
.file-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16rpx; padding: 16rpx 18rpx; margin-bottom: 12rpx;
border-radius: 14rpx; background: rgba(2, 6, 23, 0.55); border: 1rpx solid rgba(148, 163, 184, 0.12);
}
.file-main { flex: 1; min-width: 0; }
.file-name { display: block; font-size: 26rpx; color: #f1f5f9; word-break: break-all; line-height: 1.45; }
.file-meta { display: block; font-size: 22rpx; color: #94a3b8; margin-top: 6rpx; }
.file-meta.ok { color: #5eead4; }
.file-remove {
flex-shrink: 0; font-size: 24rpx; color: #f97316; padding: 8rpx 12rpx;
}
.body-img-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 12rpx;
}
.body-img-cell {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 14rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.18);
}
.body-img-thumb {
width: 100%;
height: 100%;
display: block;
}
.body-img-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
}
.body-img-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.prompt-textarea { min-height: 200rpx; }
.generate-btn {
margin-top: 20rpx; height: 88rpx; border-radius: 16rpx;
border: 1rpx solid rgba(34, 211, 238, 0.55); color: #a5f3fc; font-size: 30rpx; font-weight: 700;
display: flex; align-items: center; justify-content: center;
background: rgba(8, 47, 73, 0.45);
}
.generate-btn-loading { opacity: 0.7; pointer-events: none; }
.toolbar { margin-top: 12rpx; margin-bottom: 12rpx; display: flex; gap: 12rpx; }
.tool-btn {
padding: 10rpx 18rpx; border-radius: 999rpx; font-size: 22rpx;
color: #67e8f9; border: 1rpx solid rgba(34, 211, 238, 0.35); background: rgba(8, 47, 73, 0.45);
}
.textarea {
width: 100%; min-height: 380rpx; color: #fff; font-size: 28rpx; line-height: 1.65;
background: transparent;
}
.hint { display: block; margin-top: 10rpx; color: #94a3b8; font-size: 22rpx; }
/* 发动态 · 配图宫格(参考朋友圈) */
.moment-card .toolbar {
margin-top: 0;
}
.moment-textarea-wrap {
margin-top: 8rpx;
padding: 20rpx 22rpx;
}
.moment-textarea {
min-height: 240rpx;
width: 100%;
font-size: 30rpx;
line-height: 1.65;
}
/* 配图宫格 */
.moment-photo-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 20rpx;
}
.moment-photo-cell {
position: relative;
width: calc((100% - 32rpx) / 3);
padding-bottom: calc((100% - 32rpx) / 3);
height: 0;
border-radius: 12rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.15);
box-sizing: border-box;
}
.moment-photo-img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.moment-photo-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
z-index: 1;
}
.moment-photo-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.moment-photo-add {
border: 2rpx dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.moment-photo-add-plus {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -52%);
font-size: 64rpx;
font-weight: 300;
color: rgba(148, 163, 184, 0.85);
line-height: 1;
}
.moment-hint {
margin-top: 16rpx;
}
/* 审核模式:盖住整页(含自定义导航),高于 .nav-bar 的 z-index: 10 */
.audit-mode-mask {
position: fixed;
.moment-photo-cell {
position: relative;
width: calc((100% - 32rpx) / 3);
padding-bottom: calc((100% - 32rpx) / 3);
height: 0;
border-radius: 14rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.15);
box-sizing: border-box;
}
.moment-photo-img {
position: absolute;
left: 0;
right: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.moment-photo-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
z-index: 1;
}
.moment-photo-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.moment-photo-add {
border: 2rpx dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.moment-photo-add-plus {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -52%);
font-size: 64rpx;
font-weight: 300;
color: rgba(148, 163, 184, 0.85);
line-height: 1;
}
/* AI 底部弹层 */
.ai-modal-root {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 600;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.ai-modal-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.58);
}
.ai-modal-panel {
position: relative;
z-index: 1;
border-radius: 28rpx 28rpx 0 0;
padding: 28rpx 28rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
max-height: 78vh;
overflow-y: auto;
background: rgba(15, 23, 42, 0.98);
border: 1rpx solid rgba(148, 163, 184, 0.22);
border-bottom: none;
box-shadow: 0 -12rpx 48rpx rgba(0, 0, 0, 0.45);
}
.ai-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.ai-modal-title {
font-size: 32rpx;
font-weight: 700;
color: #f1f5f9;
}
.ai-modal-close {
font-size: 26rpx;
color: #67e8f9;
padding: 12rpx 16rpx;
}
.ai-modal-desc {
display: block;
font-size: 22rpx;
color: #94a3b8;
line-height: 1.55;
margin-bottom: 16rpx;
}
.audit-mode-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(11, 18, 32, 0.94);
}
.audit-mode-text {
font-size: 36rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
letter-spacing: 4rpx;
}

View File

@@ -78,11 +78,15 @@ function mapRows(rows) {
return (rows || []).map((r) => {
const st = String(r.auditStatus || 'approved').toLowerCase()
const thumb = buildThumbSlots(r.images)
const contentTrim = String(r.content || '').trim()
const titleTrim = String(r.title || '').trim()
return Object.assign({}, r, {
auditStatus: st === 'pending' || st === 'rejected' ? st : 'approved',
auditLabel: auditLabel(st),
timeText: relativeTime(r.createdAt),
preview: String(r.preview || '').trim(),
contentTrim,
titleTrim,
rejectReason: String(r.rejectReason || '').trim(),
showThumbRow: thumb.showThumbRow,
thumbSlots: thumb.thumbSlots,

View File

@@ -31,9 +31,10 @@
<text class="audit-tag audit-{{item.auditStatus}}">{{item.auditLabel}}</text>
<text class="feed-time">{{item.timeText}}</text>
</view>
<text class="feed-title" wx:if="{{item.title}}">{{item.title}}</text>
<text class="feed-title feed-title-fallback" wx:elif="{{item.preview}}">{{item.preview}}</text>
<text class="feed-title feed-title-fallback" wx:else>动态</text>
<view class="feed-title" wx:if="{{item.contentTrim}}">{{item.contentTrim}}</view>
<view class="feed-title feed-title-fallback" wx:elif="{{item.titleTrim}}">{{item.titleTrim}}</view>
<view class="feed-title feed-title-fallback" wx:elif="{{item.preview}}">{{item.preview}}</view>
<view class="feed-title feed-title-fallback" wx:else>动态</view>
<view class="feed-thumb-row" wx:if="{{item.showThumbRow}}" catchtap="noop">
<view class="thumb-cell" wx:for="{{item.thumbSlots}}" wx:for-item="th" wx:key="slotIdx">

View File

@@ -146,14 +146,19 @@
}
.feed-title {
display: block;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
width: 100%;
box-sizing: border-box;
font-size: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.45;
}
.feed-title-fallback {

View File

@@ -1,5 +1,5 @@
/**
* 动态广场:超级个体图文列表(触底分页)
* 动态广场:超级个体图文列表(下拉刷新 + 触底分页加载
*/
const app = getApp()
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
@@ -85,6 +85,9 @@ function mapFeedRows(rows) {
}
Page({
/** 递增序列:下拉刷新时废弃进行中的上拉请求结果,避免列表错乱 */
loadSeq: 0,
data: {
statusBarHeight: 44,
feedList: [],
@@ -93,6 +96,8 @@ Page({
hasMore: true,
loading: true,
loadingMore: false,
/** 超级个体:右下角发动态入口 */
showSuperPublishFab: false,
},
async onLoad() {
@@ -105,6 +110,7 @@ Page({
return
}
this.reloadFeed()
this.refreshSuperPublishFab()
},
async onShow() {
@@ -120,44 +126,93 @@ Page({
if (tabBar && tabBar.loadFeatureConfig) tabBar.loadFeatureConfig()
if (tabBar && tabBar.updateSelected) tabBar.updateSelected()
}
this.refreshSuperPublishFab()
},
/** 仅超级个体展示发动态悬浮按钮 */
async refreshSuperPublishFab() {
const userId = app.globalData.userInfo?.id
let show = false
if (userId && app.globalData.isLoggedIn) {
try {
const res = await app.request({
url: `/api/miniprogram/my/super-stats?userId=${encodeURIComponent(userId)}`,
silent: true,
})
show = !!(res?.success && res.data?.isSuperIndividual === true)
} catch (_) {
show = false
}
}
this.setData({ showSuperPublishFab: show })
},
goPublishMoment() {
wx.navigateTo({ url: '/pages/super-article-editor/super-article-editor' })
},
onReachBottom() {
this.loadMore()
},
async onPullDownRefresh() {
try {
await app.getAuditMode()
} catch (_) {}
if (app.globalData.auditMode) {
wx.stopPullDownRefresh()
return
}
try {
await this.reloadFeed()
await this.refreshSuperPublishFab()
} finally {
wx.stopPullDownRefresh()
}
},
async reloadFeed() {
this.loadSeq += 1
const seq = this.loadSeq
this.setData({
loading: true,
loadingMore: false,
page: 1,
feedList: [],
hasMore: true,
total: 0,
})
try {
await this.fetchPage(1, true)
await this.fetchPage(1, true, seq)
} finally {
this.setData({ loading: false })
if (seq === this.loadSeq) {
this.setData({ loading: false })
}
}
},
async loadMore() {
if (this.data.loading || this.data.loadingMore || !this.data.hasMore) return
const seq = this.loadSeq
const next = this.data.page + 1
this.setData({ loadingMore: true })
try {
await this.fetchPage(next, false)
await this.fetchPage(next, false, seq)
} finally {
this.setData({ loadingMore: false })
}
},
async fetchPage(page, replace) {
async fetchPage(page, replace, seq) {
const res = await app.request({
url: `/api/miniprogram/super/articles/feed?page=${encodeURIComponent(String(page))}&pageSize=${PAGE_SIZE}`,
silent: true,
})
if (seq !== this.loadSeq) {
return
}
if (!res?.success) {
if (seq !== this.loadSeq) return
wx.showToast({ title: String(res?.error || '加载失败'), icon: 'none' })
if (replace) this.setData({ feedList: [], hasMore: false, total: 0 })
return

View File

@@ -2,7 +2,7 @@
"usingComponents": {
"icon": "/components/icon/icon"
},
"enablePullDownRefresh": false,
"enablePullDownRefresh": true,
"onReachBottomDistance": 160,
"backgroundTextStyle": "light",
"backgroundColor": "#0b1220"

View File

@@ -58,4 +58,15 @@
<text wx:elif="{{!hasMore}}">已经到底啦</text>
</view>
</view>
<!-- 超级个体:右下角发动态(层级高于自定义 TabBar -->
<view
wx:if="{{showSuperPublishFab}}"
class="moments-fab-publish"
hover-class="moments-fab-publish-hover"
bindtap="goPublishMoment"
aria-label="发动态"
>
<text class="moments-fab-plus">+</text>
</view>
</view>

View File

@@ -166,3 +166,38 @@
font-size: 24rpx;
color: #64748b;
}
/* 超级个体发动态:右下角圆钮(与读页分享 FAB、TabBar 品牌青一致) */
.moments-fab-publish {
position: fixed;
right: 32rpx;
bottom: calc(156rpx + env(safe-area-inset-bottom));
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #00ced1 0%, #20b2aa 100%);
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.42);
z-index: 1002;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.moments-fab-publish-hover {
opacity: 0.92;
}
.moments-fab-publish:active {
transform: scale(0.94);
}
/* 原生「+」,避免 icon 组件 SVG 在 FAB 内偶发不渲染 */
.moments-fab-plus {
font-size: 56rpx;
font-weight: 300;
color: #ffffff;
line-height: 1;
margin-top: -4rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
}

View File

@@ -0,0 +1,12 @@
# 超级个体「发动态」→ AI 生成OpenAI 兼容协议)
# 智增增文档https://doc.zhizengzeng.com/doc-3979947
# 接入端点基地址(勿省略 /v1
SUPER_ARTICLE_AI_BASE_URL=https://api.zhizengzeng.com/v1
# 控制台创建的 API Key勿提交到 Git复制到本机 .env / .env.development
SUPER_ARTICLE_AI_API_KEY=
# 模型名以智增增控制台/文档为准gpt-4o-mini 支持文本与参考图vision
SUPER_ARTICLE_AI_MODEL=gpt-4o-mini
# 用户上传参考图时:自动改用视觉模型多模态分析图片(与 SUPER_ARTICLE_AI_MODEL 可为不同模型)
SUPER_ARTICLE_AI_VISION_MODEL=gpt-4o-mini
# 若未配置以上项,则回退使用 OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL视觉模型缺省为 gpt-4o-mini

View File

@@ -72,6 +72,12 @@ type Config struct {
OpenAIAPIKey string // OPENAI_API_KEY
OpenAIBaseURL string // OPENAI_BASE_URL默认 https://api.openai.com/v1
OpenAIModel string // OPENAI_MODEL默认 gpt-4o-mini
// 超级个体「发动态」AI 生成(默认同上;可单独指向智增增等网关,避免与其它 OPENAI 用途混 Key
SuperArticleAIAPIKey string // SUPER_ARTICLE_AI_API_KEY空则沿用 OPENAI_API_KEY
SuperArticleAIBaseURL string // SUPER_ARTICLE_AI_BASE_URL空则沿用 OPENAI_BASE_URL
SuperArticleAIModel string // SUPER_ARTICLE_AI_MODEL空则沿用 OPENAI_MODEL纯文本生成
SuperArticleAIVisionModel string // SUPER_ARTICLE_AI_VISION_MODEL有参考图时用默认 gpt-4o-mini
}
// BaseURLJoin 将路径拼接到 BaseURLpath 应以 / 开头
@@ -326,6 +332,23 @@ func Load() (*Config, error) {
aiModel = "gpt-4o-mini"
}
superArticleAIKey := strings.TrimSpace(os.Getenv("SUPER_ARTICLE_AI_API_KEY"))
if superArticleAIKey == "" {
superArticleAIKey = aiAPIKey
}
superArticleAIBase := strings.TrimSpace(os.Getenv("SUPER_ARTICLE_AI_BASE_URL"))
if superArticleAIBase == "" {
superArticleAIBase = aiBaseURL
}
superArticleAIModel := strings.TrimSpace(os.Getenv("SUPER_ARTICLE_AI_MODEL"))
if superArticleAIModel == "" {
superArticleAIModel = aiModel
}
superArticleAIVisionModel := strings.TrimSpace(os.Getenv("SUPER_ARTICLE_AI_VISION_MODEL"))
if superArticleAIVisionModel == "" {
superArticleAIVisionModel = "gpt-4o-mini"
}
cfg := &Config{
Port: port,
Mode: mode,
@@ -362,6 +385,10 @@ func Load() (*Config, error) {
OpenAIAPIKey: aiAPIKey,
OpenAIBaseURL: aiBaseURL,
OpenAIModel: aiModel,
SuperArticleAIAPIKey: superArticleAIKey,
SuperArticleAIBaseURL: superArticleAIBase,
SuperArticleAIModel: superArticleAIModel,
SuperArticleAIVisionModel: superArticleAIVisionModel,
}
// 生产环境GIN_MODE=release强制校验敏感配置禁止使用默认值

View File

@@ -58,6 +58,7 @@ func AdminSuperArticlesList(c *gin.Context) {
list := make([]gin.H, 0, len(rows))
for _, r := range rows {
a := authorMap[strings.TrimSpace(r.UserID)]
an, aa := resolveSuperArticleAuthorDisplay(a)
list = append(list, gin.H{
"id": r.ID,
"userId": r.UserID,
@@ -66,8 +67,8 @@ func AdminSuperArticlesList(c *gin.Context) {
"images": parseSuperArticleImagesJSON(r.Images),
"auditStatus": strings.TrimSpace(r.AuditStatus),
"rejectReason": strings.TrimSpace(r.RejectReason),
"authorNickname": strings.TrimSpace(a.Nickname),
"authorAvatar": strings.TrimSpace(a.Avatar),
"authorNickname": an,
"authorAvatar": aa,
"createdAt": r.CreatedAt,
"updatedAt": r.UpdatedAt,
})

View File

@@ -98,8 +98,9 @@ func H5SuperArticlePage(c *gin.Context) {
}
authorMap := loadSuperArticleAuthorMap([]string{row.UserID})
author := authorMap[row.UserID]
nick := strings.TrimSpace(author.Nickname)
author := authorMap[strings.TrimSpace(row.UserID)]
an, aa := resolveSuperArticleAuthorDisplay(author)
nick := an
if nick == "" {
nick = "超级个体"
}
@@ -141,7 +142,7 @@ func H5SuperArticlePage(c *gin.Context) {
ogImage = h5AbsolutizeImage(strings.TrimSpace(articleImages[0]), cfg.BaseURL)
}
if ogImage == "" {
ogImage = strings.TrimSpace(author.Avatar)
ogImage = aa
if ogImage != "" {
ogImage = h5AbsolutizeImage(ogImage, cfg.BaseURL)
}

View File

@@ -22,15 +22,47 @@ import (
const aiArticleSystemPrompt = `你是「卡若创业派对」的内容创作助手,帮助超级个体写出真实、有深度的商业与创业内容文章。
写作要求:
- 标题精炼有力≤30字有吸引力点明核心价值
- 正文600-1000字有真实故事或案例有观点洞察语气自然真诚
- 风格:像朋友分享经验,而非说教;有画面感,接地气
- 不要使用"首先、其次、最后"等模板化结构词
若用户提供的素材末尾附有「写文章规则提示词」或以「---」分隔的写作规范段落你必须优先、严格按该段落成稿体裁、字数、人称、版式、Markdown 交付与自检等)。
严格输出 JSON不要有任何额外文字
输出封装(始终遵守):只输出一段合法 JSON不要有任何 JSON 外的文字。title 为主标题纯文本(可与正文首行 Markdown 标题语义一致,但不要带「#」前缀content 为正文全文,可为 Markdown含 **加粗**、分段、---、HTML 注释等),字符串内需合法 JSON 转义。
若用户未附带上述写作规范段落则默认标题精炼有力≤30字正文约6001000字语气自然真诚像朋友分享经验不要使用"首先、其次、最后"等模板化结构词。
严格输出 JSON
{"title":"文章标题","content":"文章正文"}`
// superArticleWritingRulesSuffix 在服务端拼接到用户素材末尾(与 开发文档/ai开发文档.txt 写作规则段落保持一致)。
const superArticleWritingRulesSuffix = `写文章规则提示词:
写文章。严格按以下规则成稿。
【材料】用户提供:本场聊天记录/纪要/妙记要点、场次号、主题短句;写作前先按规范阅读飞书运营报表,把报表上有的时长、场观、进房等具体数字写进开篇(只写一次)。
【人称与禁用】叙述全程用「我」,禁止「房主」。禁止「这边」「那边」。禁止「回答说,……」——问句后直接接我的陈述。语气偏强势:干脆、笃定,少「可能/也许」。「卡若」每篇最多 1 次,可完全不出现。
【刺点三对齐】正文第一行标题「# 第X场短句」、磁盘文件名、开篇刺激性金句三者同一刺点不写空泛抒情。
【结构与比例】
- 默认单场 采用「2026 每日派对干货 / 派对体」:三块约 40% / 20%30% / 余下约 30%块与块之间必须有过渡口语每一块结束必须有一段「段末小结」单独成段3050 个汉字一句,禁止出现「干货」等标签字。
【开篇】先有冲突或世俗金句,再切入场景。固定数据句式(报表有则写具体数,没有的项不写):「第 X 场X 分钟;场观 XXXXX进房 XXX 人。」不写「约」、不写「见报表」。时长/场观/进房仅在开篇出现一次。
【中段】推进用时间线或事件线多写对话「X 号问」「有人问」);我的话直接陈述。约在全文 50% 处插入一段浓缩要点 36 条2040 字/条),前后用 --- 分隔,条目中禁止「干货」二字。
【分享句】约在 20% 处一句、结尾一句,各 ≤50 字,紧扣本节,禁止「干货:」字样。
【强调·派对体成稿必选】Markdown **加粗**:至少 2 句完整立场/结论句(分布在不同段落);至少 3 个不同的重点关键词加粗。禁止堆砌无意义加粗。
【结尾】升华主题派对体可有文末小结36 条短金句)+ 总结段落 + 一句收尾金句。
【字数与真实性】小节 30005000 字;忠于来源不改原意;所有数字必须有依据、写具体算法/金额/人次等Soul 侧常用口径可参考规范中的曝光/进房/成本写法。
【隐晦植入】文中自然散布 24 处联系管理、做切片、做副业相关一句不单段喊话随机10%加率不用每篇文章都加
【版式强制】一句一行,句间空一行;分号也拆成行;段落之间空行;大段堆砌禁止。
【交付】输出 Markdown标题一行「# 第X场…」成稿末尾按模板追加 HTML 注释自检:「我」字数/次数、本场关键字频次、关键字加粗处数、重点句/词加粗是否达标。`
const (
maxArticleMaterialFetchBody = 512 * 1024 // 单素材最大读取
maxCombinedReferenceRunes = 60000 // 合并后截断上限(含提示与素材摘要)
@@ -40,11 +72,11 @@ const (
// 根据提示词 + 可选参考文本 / 素材文件 URL本站上传链接 / 图片 URL调用 AI 生成草稿(仅超级个体)。
func MiniprogramSuperArticleGenerate(c *gin.Context) {
var req struct {
UserID string `json:"userId"`
Description string `json:"description"` // 提示词(与原字段兼容)
ReferenceText string `json:"referenceText"` // 小程序端可读出的纯文本(多文件拼接)
MaterialURLs []string `json:"materialUrls"` // 上传后的文件 URL服务端在安全域名内抓取文本
ImageURLs []string `json:"imageUrls"`
UserID string `json:"userId"`
Description string `json:"description"` // 提示词(与原字段兼容)
ReferenceText string `json:"referenceText"` // 小程序端可读出的纯文本(多文件拼接)
MaterialURLs []string `json:"materialUrls"` // 上传后的文件 URL服务端在安全域名内抓取文本
ImageURLs []string `json:"imageUrls"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "参数错误"})
@@ -86,8 +118,8 @@ func MiniprogramSuperArticleGenerate(c *gin.Context) {
}
cfg := config.Get()
if cfg == nil || strings.TrimSpace(cfg.OpenAIAPIKey) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 功能暂未开放,请联系管理员配置"})
if cfg == nil || strings.TrimSpace(cfg.SuperArticleAIAPIKey) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "AI 功能暂未开放,请在服务端配置 SUPER_ARTICLE_AI_API_KEY 或 OPENAI_API_KEY"})
return
}
@@ -102,6 +134,7 @@ func MiniprogramSuperArticleGenerate(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": errMsg})
return
}
combined = appendSuperArticleWritingRules(combined)
title, content, err := aiGenerateArticle(cfg, combined, validImageURLs)
if err != nil {
@@ -156,6 +189,32 @@ func assembleSuperArticlePrompt(cfg *config.Config, prompt, refText string, mate
return combined, ""
}
// appendSuperArticleWritingRules 在用户提示词与参考素材组装结果末尾追加固定写作规则(保证规则段不被 maxCombinedReferenceRunes 截断)。
func appendSuperArticleWritingRules(combined string) string {
suffix := strings.TrimSpace(superArticleWritingRulesSuffix)
if suffix == "" {
return strings.TrimSpace(combined)
}
suffixRunes := len([]rune(suffix))
sep := "\n\n---\n\n"
sepLen := len([]rune(sep))
maxBody := maxCombinedReferenceRunes - suffixRunes - sepLen
if maxBody < 0 {
maxBody = 0
}
base := strings.TrimSpace(combined)
if base != "" {
rs := []rune(base)
if len(rs) > maxBody {
base = string(rs[:maxBody]) + "\n\n上文参考已截断"
}
}
if base == "" {
return suffix
}
return base + sep + suffix
}
func snippetNameFromURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
@@ -281,34 +340,66 @@ func looksLikeUTF8ArticleRef(b []byte) bool {
return float64(printable)/float64(total) > 0.90
}
// superArticleModelLikelySupportsVision 粗略判断 chat/completions 模型是否适合传 image_url智增增/OpenAI 等多网关)。
func superArticleModelLikelySupportsVision(model string) bool {
m := strings.ToLower(strings.TrimSpace(model))
if m == "" {
return false
}
if strings.Contains(m, "vision") || strings.Contains(m, "-vl") || strings.Contains(m, "vqa") {
return true
}
if strings.Contains(m, "gpt-4") || strings.Contains(m, "gpt-5") || strings.Contains(m, "gpt-4o") {
return true
}
if strings.Contains(m, "claude-3") || strings.Contains(m, "claude-4") {
return true
}
if strings.Contains(m, "glm-4v") || strings.Contains(m, "qwen-vl") || strings.Contains(m, "gemini") {
return true
}
return false
}
// aiGenerateArticle 调用 OpenAI 兼容 API 生成文章标题和正文。
// 若有图片 URL模型支持视觉,以多模态格式传入;否则退化为纯文本
// 若有参考图片 URL:使用多模态 user contenttext + image_url当前主模型支持 vision 时自动改用 SUPER_ARTICLE_AI_VISION_MODEL默认同为 gpt-4o-mini
func aiGenerateArticle(cfg *config.Config, description string, imageURLs []string) (title, content string, err error) {
apiKey := strings.TrimSpace(cfg.OpenAIAPIKey)
baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.OpenAIBaseURL), "/")
apiKey := strings.TrimSpace(cfg.SuperArticleAIAPIKey)
baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.SuperArticleAIBaseURL), "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
model := strings.TrimSpace(cfg.OpenAIModel)
if model == "" {
model = "gpt-4o-mini"
baseModel := strings.TrimSpace(cfg.SuperArticleAIModel)
if baseModel == "" {
baseModel = "gpt-4o-mini"
}
// 判断模型是否支持视觉OpenAI gpt-4 系列 / claude-3 系列)
supportsVision := len(imageURLs) > 0 && (
strings.Contains(model, "gpt-4") ||
strings.Contains(model, "claude-3") ||
strings.Contains(model, "vision"))
requestModel := baseModel
useVision := len(imageURLs) > 0
if useVision {
if superArticleModelLikelySupportsVision(baseModel) {
requestModel = baseModel
} else {
vm := strings.TrimSpace(cfg.SuperArticleAIVisionModel)
if vm == "" {
vm = "gpt-4o-mini"
}
requestModel = vm
}
}
var userContent interface{}
if supportsVision {
if useVision {
parts := []map[string]interface{}{
{"type": "text", "text": buildUserPrompt(description, imageURLs, true)},
}
for _, u := range imageURLs {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]string{"url": u, "detail": "low"},
"type": "image_url",
"image_url": map[string]string{
"url": u,
"detail": "low",
},
})
}
userContent = parts
@@ -317,16 +408,16 @@ func aiGenerateArticle(cfg *config.Config, description string, imageURLs []strin
}
reqBody, _ := json.Marshal(map[string]interface{}{
"model": model,
"model": requestModel,
"messages": []map[string]interface{}{
{"role": "system", "content": aiArticleSystemPrompt},
{"role": "user", "content": userContent},
},
"temperature": 0.8,
"max_tokens": 2000,
"max_tokens": 8192,
})
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/chat/completions", bytes.NewReader(reqBody))
@@ -341,7 +432,10 @@ func aiGenerateArticle(cfg *config.Config, description string, imageURLs []strin
return "", "", fmt.Errorf("请求 AI 服务失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
body, errRead := io.ReadAll(resp.Body)
if errRead != nil {
return "", "", fmt.Errorf("读取 AI 响应失败: %v", errRead)
}
var apiResp struct {
Choices []struct {
@@ -354,10 +448,23 @@ func aiGenerateArticle(cfg *config.Config, description string, imageURLs []strin
} `json:"error"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", "", fmt.Errorf("AI 服务 HTTP %d", resp.StatusCode)
}
return "", "", fmt.Errorf("解析 AI 响应失败")
}
if apiResp.Error != nil {
return "", "", fmt.Errorf("%s", apiResp.Error.Message)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if apiResp.Error != nil && strings.TrimSpace(apiResp.Error.Message) != "" {
return "", "", fmt.Errorf("%s", strings.TrimSpace(apiResp.Error.Message))
}
snippet := strings.TrimSpace(string(body))
if len(snippet) > 400 {
snippet = snippet[:400] + "…"
}
return "", "", fmt.Errorf("AI 服务 HTTP %d %s", resp.StatusCode, snippet)
}
if apiResp.Error != nil && strings.TrimSpace(apiResp.Error.Message) != "" {
return "", "", fmt.Errorf("%s", strings.TrimSpace(apiResp.Error.Message))
}
if len(apiResp.Choices) == 0 || apiResp.Choices[0].Message.Content == "" {
return "", "", fmt.Errorf("AI 无返回内容")
@@ -410,12 +517,12 @@ func parseArticleJSON(raw string) (title, content string, err error) {
return "", "", fmt.Errorf("AI 生成内容不完整,请重试")
}
// 截断超限内容
if tr := []rune(title); len(tr) > 40 {
title = string(tr[:40])
// 截断超限内容(派对体长文可到数千字,预留上限避免极端响应撑爆存储)
if tr := []rune(title); len(tr) > 120 {
title = string(tr[:120])
}
if cr := []rune(content); len(cr) > 5000 {
content = string(cr[:5000])
if cr := []rune(content); len(cr) > 20000 {
content = string(cr[:20000])
}
return title, content, nil

View File

@@ -14,9 +14,24 @@ import (
)
type superArticleAuthorRow struct {
ID string `gorm:"column:id"`
Nickname string `gorm:"column:nickname"`
Avatar string `gorm:"column:avatar"`
ID string `gorm:"column:id"`
Nickname string `gorm:"column:nickname"`
Avatar string `gorm:"column:avatar"`
VipName string `gorm:"column:vip_name"`
VipAvatar string `gorm:"column:vip_avatar"`
}
// resolveSuperArticleAuthorDisplay 展示用昵称/头像:普通资料优先,超级个体常用 vip_* 兜底。
func resolveSuperArticleAuthorDisplay(a superArticleAuthorRow) (nickname, avatar string) {
nickname = strings.TrimSpace(a.Nickname)
if nickname == "" {
nickname = strings.TrimSpace(a.VipName)
}
avatar = strings.TrimSpace(a.Avatar)
if avatar == "" {
avatar = strings.TrimSpace(a.VipAvatar)
}
return nickname, avatar
}
func loadSuperArticleAuthorMap(ids []string) map[string]superArticleAuthorRow {
@@ -26,7 +41,7 @@ func loadSuperArticleAuthorMap(ids []string) map[string]superArticleAuthorRow {
}
var rows []superArticleAuthorRow
db := database.DB()
_ = db.Table("users").Select("id", "nickname", "avatar").Where("id IN ?", ids).Find(&rows).Error
_ = db.Table("users").Select("id", "nickname", "avatar", "vip_name", "vip_avatar").Where("id IN ?", ids).Find(&rows).Error
for _, r := range rows {
key := strings.TrimSpace(r.ID)
if key == "" {
@@ -142,6 +157,7 @@ func MiniprogramSuperArticleFeed(c *gin.Context) {
preview = string([]rune(preview)[:120]) + "..."
}
a := authorMap[strings.TrimSpace(r.UserID)]
an, aa := resolveSuperArticleAuthorDisplay(a)
list = append(list, gin.H{
"id": r.ID,
"userId": r.UserID,
@@ -149,8 +165,8 @@ func MiniprogramSuperArticleFeed(c *gin.Context) {
"content": r.Content,
"preview": preview,
"images": parseSuperArticleImagesJSON(r.Images),
"authorNickname": strings.TrimSpace(a.Nickname),
"authorAvatar": strings.TrimSpace(a.Avatar),
"authorNickname": an,
"authorAvatar": aa,
"createdAt": r.CreatedAt,
})
}
@@ -343,6 +359,7 @@ func MiniprogramSuperArticleList(c *gin.Context) {
authorMap := loadSuperArticleAuthorMap([]string{authorUserID})
author := authorMap[authorUserID]
an, aa := resolveSuperArticleAuthorDisplay(author)
list := make([]gin.H, 0, len(rows))
for _, r := range rows {
content := strings.TrimSpace(r.Content)
@@ -357,8 +374,8 @@ func MiniprogramSuperArticleList(c *gin.Context) {
"content": r.Content,
"preview": preview,
"images": parseSuperArticleImagesJSON(r.Images),
"authorNickname": strings.TrimSpace(author.Nickname),
"authorAvatar": strings.TrimSpace(author.Avatar),
"authorNickname": an,
"authorAvatar": aa,
"createdAt": r.CreatedAt,
})
}
@@ -409,6 +426,7 @@ func MiniprogramSuperArticleMine(c *gin.Context) {
authorMap := loadSuperArticleAuthorMap([]string{userID})
author := authorMap[userID]
an, aa := resolveSuperArticleAuthorDisplay(author)
list := make([]gin.H, 0, len(rows))
for _, r := range rows {
content := strings.TrimSpace(r.Content)
@@ -427,8 +445,8 @@ func MiniprogramSuperArticleMine(c *gin.Context) {
"content": r.Content,
"preview": preview,
"images": parseSuperArticleImagesJSON(r.Images),
"authorNickname": strings.TrimSpace(author.Nickname),
"authorAvatar": strings.TrimSpace(author.Avatar),
"authorNickname": an,
"authorAvatar": aa,
"createdAt": r.CreatedAt,
"auditStatus": ast,
"rejectReason": strings.TrimSpace(r.RejectReason),
@@ -474,7 +492,8 @@ func MiniprogramSuperArticleDetail(c *gin.Context) {
ast = model.SuperArticleAuditApproved
}
authorMap := loadSuperArticleAuthorMap([]string{row.UserID})
author := authorMap[row.UserID]
author := authorMap[strings.TrimSpace(row.UserID)]
an, aa := resolveSuperArticleAuthorDisplay(author)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
@@ -483,8 +502,8 @@ func MiniprogramSuperArticleDetail(c *gin.Context) {
"title": row.Title,
"content": row.Content,
"images": parseSuperArticleImagesJSON(row.Images),
"authorNickname": strings.TrimSpace(author.Nickname),
"authorAvatar": strings.TrimSpace(author.Avatar),
"authorNickname": an,
"authorAvatar": aa,
"createdAt": row.CreatedAt,
"auditStatus": ast,
"rejectReason": strings.TrimSpace(row.RejectReason),

View File

@@ -6,6 +6,27 @@
## 一、近期同步(摘要)
### 2026-05-09 超级个体「动态详情」并入小程序阅读页(文章详情)
- **动机**:产品与章节阅读统一为同一套「文章详情」承载页;动态分享与 H5 拉起小程序路径对齐。
- **小程序**`pages/read/read?superArticleId={数字}`(兼容 `saId``data.pageMode``super_article` 时渲染动态 UI顶栏文案「文章详情」。解析逻辑见 `miniprogram/utils/superArticleDetail.js`
- **兼容**`pages/super-article-detail` 保留注册,`onLoad``redirectTo``read?superArticleId=`
- **入口更新**`super-article-mine``super-moments``member-detail` 等跳转已改为阅读页 query。
- **后端**`h5_super_article.go` 中拉起小程序 path 已改为 `pages/read/read?superArticleId=...`
- **文档**`开发文档/4、前端/当前小程序开发细则.md` §2.2.2。
### 2026-05-09 小程序「我的」页快捷入口 UI 优化
- **范围**`pages/my/my.wxml``pages/my/my.wxss`,以及 `miniprogram/assets/icons/list-teal.svg``share-teal.svg``wallet-teal.svg``users-teal.svg``book-arrow-teal.svg`
- **说明**:快捷入口图标改为渐变面性 SVG每项增加圆形发光底托`stat-icon-wrap`)、卡片渐变边框与弱外发光;余额主文案使用 `stat-num-balance`(渐变字,低版本可降级纯色)。**`book-arrow-teal.svg`** 与「最近阅读」卡片标题共用,改图标时需注意两处观感一致。
- **交叉引用**:实现约定见 `开发文档/4、前端/当前小程序开发细则.md` §2.2.1。
### 2026-05-09 超级个体「AI 写文章」:多文件 + 提示词 + 生成草稿
- **小程序**`pages/super-article-editor/super-article-editor``wx.chooseMessageFile` 多选;图片上传 `folder=article-images`,附件 `folder=book-attachments`,文本类可本地读入后随 `referenceText` 提交;`POST /api/miniprogram/super/articles/generate` 建议超时 120s上传走 `utils/miniprogramUpload.js`Bearer
- **后端**`/super/articles/generate` 支持 `description``referenceText``materialUrls``imageUrls`,至少其一;`materialUrls` 仅允许白名单域拉取(防 SSRF
- **文档**`开发文档/5、接口/API接口完整文档.md` §1.3、`开发文档/三端需求业务对齐-小程序与API.md` §四、`开发文档/4、前端/当前小程序开发细则.md` §2.2.3、`.cursor/skills/super-individual-article/SKILL.md`
### 2026-04-28 超级个体发文章接口对接与排障
- **目标**:小程序超级个体发布文章,统一走 `super_articles` 表对应接口,避免与管理端文章体系混用。
@@ -34,4 +55,4 @@
---
**最后更新**2026-04-15
**最后更新**2026-05-09

View File

@@ -1,7 +1,7 @@
# 当前小程序开发细则
> 汇总当前 Soul 创业派对小程序的架构、经验与规划,便于新人上手与后续迭代。
> 最后整理2026-02
> 最后整理2026-05-09§2.2.2 阅读页承载超级个体动态§2.2.3 AI 发文章)
---
@@ -52,6 +52,33 @@ miniprogram/
非 Tab 页:阅读页 read、推广中心 referral、订单 purchases、设置 settings、搜索 search、关于 about。
### 2.2.1 我的页「快捷入口」UI 约定2026-05
- **位置**`pages/my/my` 中「快捷入口」卡片:`stats-card``stats-grid` → 若干 `stat-box`
- **结构**:每个入口为 `stat-box`;图标外包一层 `stat-icon-wrap`(圆形渐变底、描边与弱外发光);列表项设 `hover-class="stat-box-hover"` 提供按压反馈。
- **图标资源**`miniprogram/assets/icons/``list-teal`(订单)、`share-teal`(代付)、`wallet-teal`(余额)、`users-teal`(客资,条件展示)、`book-arrow-teal`(发文,条件展示)。均为青绿渐变面性 SVG。
- **共用资源**`book-arrow-teal.svg` 同时用于同页「最近阅读」卡片标题左侧图标,修改该文件需兼顾两处视觉。
- **余额文案**:主数字 class 为 `stat-num-balance`(渐变字 + 等宽数字倾向);若某端 `background-clip: text` 表现不佳,可降级为纯色 `#4FD1C5`
### 2.2.2 阅读页 `read` 双模式:书籍章节 + 超级个体动态(文章详情)
- **同一页面**`pages/read/read` 既承载**书籍章节阅读**(付费墙、上下篇、阅读进度等),也承载 **超级个体 UGC 动态的正文详情展示**(与原先独立「动态详情」页合并)。
- **区分参数(必选其一语义)**
- **章节**`id`(章节 id、可选 `mid`(章节 mid、及原有 `ref`/`gift`/scene 等;逻辑不变。
- **动态****`superArticleId`**(数字字符串,表 `super_articles.id`);兼容简写 **`saId`**。**不要**用章节用的 `id` 传动态 id避免与章节 id 混淆。
- **落地路径示例**`/pages/read/read?superArticleId=123`;分享卡片 / 朋友圈已为该格式。
- **兼容**`pages/super-article-detail/super-article-detail?id=` 仍注册在 `app.json`,进入后 **`redirectTo`** 到上述 `read?superArticleId=`(旧分享链接可用)。
- **相关页面**`super-article-editor`(编辑/发布)、`super-article-mine`(我的动态列表)、`super-moments`(广场)、`member-detail`(嘉宾动态入口)等跳转详情时统一 **`navigateTo` 阅读页 + `superArticleId`**。
- **工具**:动态详情用到的正文/HTML、配图解析见 `miniprogram/utils/superArticleDetail.js`(由 `read.js` 引用)。
- **后端 H5**:拉起小程序路径已对齐为 `pages/read/read?superArticleId=...`(见 `soul-api/internal/handler/h5_super_article.go`)。
### 2.2.3 超级个体「发文章」页:多文件参考 + AI 生成 + 发布2026-05
- **页面**`pages/super-article-editor/super-article-editor`
- **流程**:选择多份参考文件 → 填写提示词 → **生成文章**`POST /api/miniprogram/super/articles/generate`,建议 `timeout` ≥ 120s自动回填标题与正文 → 可微调后 **发布文章**`POST /api/miniprogram/super/articles`)。
- **选文件**`wx.chooseMessageFile` 多选。**图片**走 `POST /api/miniprogram/upload` + `folder=article-images`**PDF/Office 等附件**走 `folder=book-attachments`**`.txt` / `.md` / `.json` / `.csv` 等**可在端上 `readFile` 后并入请求体 `referenceText`(详见 `开发文档/5、接口/API接口完整文档.md` §1.3)。
- **上传封装**`utils/miniprogramUpload.js`Bearer 与 baseUrl 一致)。
### 2.3 全局状态app.js globalData
- `userInfo`登录后用户信息id、openId、nickname、purchasedSections、hasFullBook、referralCode、referralCount 等)。
@@ -95,6 +122,7 @@ miniprogram/
### 3.4 分享与落地
- 阅读页分享:`onShareAppMessage` / `onShareTimeline``id=章节ID&ref=当前用户邀请码`,落地后 ref 写入 storage绑定与订单归属同上。
- **超级个体动态**(阅读页 `pageMode=super_article`):分享路径为 `/pages/read/read?superArticleId=…`,与章节链路分离。
- 文章/章节分销与全局同一套不按“哪篇文章带来”单独分成或统计仅按“谁发的链接ref=谁)”归属。
---

View File

@@ -73,7 +73,59 @@ Authorization: Bearer admin-token-secret
**接口**: `GET /api/miniprogram/super/articles/{id}`
**说明**:
- 返回文章详情(标题、正文、作者昵称头像、创建时间
- 返回文章详情(标题、正文、配图、作者昵称头像、创建时间、审核状态等,以后端实现为准)。
- 审核中非公开稿件:作者查看须在请求中带 `viewerUserId`(当前用户 openId与 soul-api `MiniprogramSuperArticleDetail` 一致。
### 删除文章(作者本人)
**接口**: `DELETE /api/miniprogram/super/articles/{id}?userId={openId}`
**兼容**: `POST /api/miniprogram/super/articles/{id}/delete?userId=`(语义同 DELETE便于调试
### 小程序落地页(详情 UI
- **统一使用阅读页**`pages/read/read?superArticleId={id}`(兼容 query **`saId`**)。**勿**使用章节参数 `id` 传递动态主键,以免与章节 id 混淆。
- **旧版路径**`pages/super-article-detail/super-article-detail?id=` 仍可用,进入后重定向至上述路径。
- **H5 拉起小程序**:服务端生成的 path 为 `pages/read/read?superArticleId=...`(见 `h5_super_article.go`)。
### AI 生成文章草稿(多文件 + 提示词)
**接口**: `POST /api/miniprogram/super/articles/generate`
**请求体**:
```json
{
"userId": "小程序当前用户 id必填",
"description": "提示词,可选;可与参考素材组合",
"referenceText": "可选,端上读取的纯文本(如多份 .txt/.md 拼接)",
"materialUrls": ["可选,附件上传接口返回的完整 URL"],
"imageUrls": ["可选,图片上传接口返回的公网 URL"]
}
```
**校验**:
- 仅**超级个体**可调(与发布接口同一身份判定)。
- `description``referenceText` 非空、`materialUrls``imageUrls` **四者至少一项有内容**;若仅有图片且无文字素材,服务端会补充默认写作说明再调模型。
**响应**:
```json
{ "success": true, "data": { "title": "...", "content": "..." } }
```
**运行依赖**: 服务端需配置 OpenAI 兼容通道(如 `OPENAI_API_KEY`,及可选 `OPENAI_BASE_URL``OPENAI_MODEL`)。
**小程序对接要点**(页面:`pages/super-article-editor/super-article-editor`:
- 会话内选文件:`wx.chooseMessageFile`(可多选)。
- **图片**`POST /api/miniprogram/upload``formData.folder = article-images`,将返回 URL 填入 `imageUrls`
- **附件**PDF、Office、zip 等):`folder = book-attachments`走附件校验与大小上限与章节附件同源策略URL 填入 `materialUrls`
- **可读文本**`.txt``.md``.json``.csv` 等):可在端上用 `FileSystemManager.readFile` 读入 UTF-8拼进 `referenceText`(过长时端上可先截断,如约 2.8 万字符再提交)。
- 上传建议使用 `utils/miniprogramUpload.js`,请求头携带 `Authorization: Bearer <token>`
- 生成接口耗时长,建议 `wx.request` / `app.request` **timeout ≥ 120000**(毫秒)。
**服务端素材拉取materialUrls**:
- 仅允许拉取 URL 路径含 **`/uploads/`**,且主机为 **配置项 `API_BASE_URL` 对应主机**或 **阿里云 OSS 域名(含 `*.aliyuncs.com`**,用于降低 SSRF 风险。
- 单文件最多读取约 **512KB**;合并后的参考正文在服务端约 **6 万字符**处截断。
- PDF/Office 等二进制文件通常无法在服务端抽出正文,接口会在提示中说明「未解析」;产品侧可引导用户改用 `.txt/.md` 或把要点写在 `description`
---

View File

@@ -7,6 +7,7 @@
| 文档 | 说明 |
|------|------|
| [**API接口完整文档.md**](./API接口完整文档.md) | Soul `/api` **主真源**REST 模块划分、鉴权说明) |
| [**文件上传与OSS.md**](./文件上传与OSS.md) | **路由摘要**OSS/本地策略与运维见 [6、后端/阿里云OSS与文件上传.md](../6、后端/阿里云OSS与文件上传.md) |
| [配置清单-完整版.md](./配置清单-完整版.md) | 环境变量与配置项 |
| [在线支付对接文档.md](./在线支付对接文档.md) | 支付对接 |
| [接口与提现.md](./接口与提现.md) | 提现与相关接口 |

View File

@@ -0,0 +1,23 @@
# 文件上传与 OSS接口侧摘要
> **完整约定与运维排障**:见 [**6、后端/阿里云OSS与文件上传.md**](../6、后端/阿里云OSS与文件上传.md)(以 `internal/oss`、`upload.go` 为准。本节仅列路由与语义避免与后端长文重复。最后更新2026-05-09。
## 路由摘要
| 方法与路径 | 鉴权 | 说明 |
|-----------|------|------|
| `POST /api/upload` | 无硬性要求 | multipart`file``folder` |
| `POST /api/admin/upload` | **Admin JWT** | **soul-admin**`ADMIN_UPLOAD_PATH` |
| `DELETE /api/upload``DELETE /api/admin/upload` | 后者须 Admin | query`path=` |
| `POST /api/miniprogram/upload` | - | 与 `UploadPost` 同源 |
| `POST /api/miniprogram/upload/image``/video` | - | `upload_content`,图可压缩 |
## 行为摘要
**先 OSS`oss_config` 有效),失败或未配置则落本地**;接口多返回 `success: true`。成功时 **`data.storage`**`oss` | `local``/api/upload` 系列)。
## 脚本
- 缺少 `super_articles` 表:`soul-api/scripts/create_super_articles.sql`
返回 [本目录 README](./README.md) · [后端完整说明](../6、后端/阿里云OSS与文件上传.md) · [开发文档索引](../索引.md)

View File

@@ -1,6 +1,6 @@
# Soul创业实验 - API密钥与配置清单
> 最后更新: 2026-01-25
> 最后更新: 2026-05-09补充 OSS 业务文档链接)
> 维护人: 卡若
> ⚠️ 本文件包含敏感信息,请勿公开
@@ -87,6 +87,8 @@
| **AccessKey ID** | `LTAI5t9zkiWmFtHG8qmtdysW` |
| **AccessKey Secret** | `xxjXnZGLNvA2zDkj0aEBSQm3XZAaro` |
**Soul 业务 OSS素材/头像等)**:以库表 `system_config.oss_config` 为主;可选环境变量 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET` 在库内 Secret 无效时兜底。详见 [**开发文档/6、后端/阿里云OSS与文件上传.md**](../6、后端/阿里云OSS与文件上传.md)。
---
## 五、数据库

View File

@@ -7,6 +7,7 @@
| 文件 | 说明 |
|------|------|
| [后端架构.md](./后端架构.md) | 服务架构与模块 |
| [**阿里云OSS与文件上传.md**](./阿里云OSS与文件上传.md) | **OSS 配置、环境变量兜底、上传策略、路由与排障**(路由短表互链 [5、接口/文件上传与OSS.md](../5、接口/文件上传与OSS.md) |
| [后端开发规范.md](./后端开发规范.md) | 编码、路由、响应约定 |
| [内容创建问题修复说明.md](./内容创建问题修复说明.md) | 内容创建专项 |
| [soul-admin与Mycontent-temp内容页对比.md](./soul-admin与Mycontent-temp内容页对比.md) | 管理端内容页对比 |

View File

@@ -0,0 +1,86 @@
# 阿里云 OSS 与文件上传soul-api
> **以代码为准**`soul-api/internal/oss`、`internal/handler/upload.go`、`internal/handler/upload_content.go`、`internal/handler/db.go`(系统设置)。
> 最后更新2026-05-09
---
## 1. 配置存哪里
| 来源 | 说明 |
|------|------|
| **主配置** | MySQL 表 `system_config``config_key = 'oss_config'``config_value` 为 JSON |
| **环境变量兜底** | 当库里的 `accessKeySecret` 为空或为占位符(如 `****`、全星号)时,可用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET` 补齐后再校验(见 `soul-api/.env.development` 注释) |
JSON 字段与管理端「系统设置 → OSS」一致常见键名
- `endpoint`:如 `oss-cn-beijing.aliyuncs.com`(可不含 `https://`
- `bucket``region`
- `accessKeyId``accessKeySecret`
- `publicBaseUrl`可选CDN 或自定义域名,`https://` 开头或裸域名皆可
**说明**RAM 控制台下发的 **AccessKey Secret 为长随机串**。若库里误存字面量 `****`,阿里云会返回 **SignatureDoesNotMatch**403与「界面脱敏」不是一回事。
---
## 2. 运行时如何读配置
- 业务统一走 **`internal/oss.LoadConfig()`**:解析 `oss_config`,再应用环境变量兜底,最后校验 bucket / endpoint / 非占位 Secret。
- **`oss.IsEnabled()`**、通用上传、`upload/image``upload/video` 均依赖同一套逻辑。
管理端 **`GET /api/admin/settings`** 中的 `ossConfig`
-**能成功 `LoadConfig()`** 时,回显 **与实际上传一致的生效字段**(含合并环境变量后的密钥),并设 `secretConfigured: true`
-**不能加载有效配置**,则仍以库中原始 JSON 为主,`secretConfigured: false`(常见于库里 Secret 为占位符且未配置环境变量兜底)。
保存:**`POST /api/admin/settings`** 的 `ossConfig` 会合并历史密钥(避免仅因前端未重填 Secret 而覆盖);合并后须能通过 **`oss.ConfigJSONIsReady`** 校验才写入。
---
## 3. 上传策略与路由
**策略(统一)**:若 `LoadConfig()` 成功则 **先上传 OSS**;若 PutObject 失败、返回空 URL**未配置有效 OSS**,则 **自动回退本地磁盘**`uploads/` 等,由 `UPLOAD_DIR` / 默认目录与 Gin `Static /uploads` 提供访问)。处理函数 **不再因 OSS 失败向客户端返回 5xx**(仍 `success: true` 时),便于线上不因 OSS 瞬时故障中断业务;服务端打 **log** 便于排障。响应 JSON 中常见 **`data.storage`**`oss` | `local`
> 说明:配置里曾出现历史环境变量 `UPLOAD_ALLOW_LOCAL_FALLBACK`**handler 已默认本地兜底**,该开关不再控制是否允许回退(详见 `internal/config/config.go` 注释)。
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| POST | `/api/admin/upload` | 管理端 JWT | 通用 multipart`file` + `folder` |
| DELETE | `/api/admin/upload` | 管理端 JWT | query `path=`,本地路径或 OSS URL |
| POST | `/api/upload` | 无硬性要求 | 与 `UploadPost` 同源 |
| DELETE | `/api/upload` | 视部署 | 同上 |
| POST | `/api/miniprogram/upload` | 小程序侧约定 | 同源 `UploadPost` |
| POST | `/api/miniprogram/upload/image` | 同上 | 图片,可压缩;内部 `ossUploadBytes` 再本地 |
| POST | `/api/miniprogram/upload/video` | 同上 | 视频;内部 `ossUploadFile` 再本地 |
小程序封装见 `miniprogram/utils/miniprogramUpload.js`。**接口侧短表**见 [5、接口/文件上传与OSS.md](../5、接口/文件上传与OSS.md)。
---
## 4. 本地与排障
### 4.1 自检脚本
仓库 **`soul-api/scripts/test_oss_connect.py`**(依赖 `pip install oss2`
- 默认读取脚本内示例 JSON**Secret 占位时必须设置环境变量** `OSS_ACCESS_KEY_SECRET``ALIYUN_OSS_ACCESS_KEY_SECRET`
- 成功则对目标 Bucket 执行 `list_objects(max_keys=1)`
### 4.2 常见现象
| 现象 | 可能原因 |
|------|----------|
| `secretConfigured: false` | 库内 Secret 无效/占位;或未设环境变量兜底 |
| 上传提示未配置 OSS | `LoadConfig()` 失败,查日志 `oss: oss_config rejected` / `parse error` |
| 管理端 Network 里 Secret 仍是 `****` | 多为 **库里就是字面量 `****`**;修好库或配置 env 并重启后,若 `LoadConfig` 成功GET settings 会回显生效密钥 |
---
## 5. 安全与运维
- **不要将含真实 OSS Secret 的 `.env` 提交到公开仓库。**
- 管理端返回完整密钥依赖 **Admin 鉴权**;若接口暴露在公网,须配合 HTTPS、IP 限制与强口令。
---
返回 [6、后端 README](./README.md) · [开发文档索引](../索引.md)

View File

@@ -0,0 +1,50 @@
智增增 OpenAI 兼容 API超级个体 AI 写文章)
基地址https://api.zhizengzeng.com/v1
接口POST /chat/completions与 OpenAI 一致)
开发文档(模型与价格):
https://doc.zhizengzeng.com/doc-3979947
密钥:请在智增增控制台创建,写入 soul-api 环境变量(勿明文存入仓库):
- SUPER_ARTICLE_AI_API_KEY推荐与其他 OPENAI 用途隔离)
- 或 OPENAI_API_KEY
- SUPER_ARTICLE_AI_MODEL默认文本与配图均可优先使用如 gpt-4o-mini支持 vision
- SUPER_ARTICLE_AI_VISION_MODEL有参考图时自动改用、与图片一并分析的多模态模型默认 gpt-4o-mini需在控制台支持 vision
完整示例见仓库soul-api/.env.super-article-ai.example
---
以下「写文章规则提示词」由服务端在调用大模型前自动追加在用户提示词与参考素材之后(无须小程序重复传);若需修改规则,请同步改 soul-api/internal/handler/miniprogram_ai_article.go 常量 superArticleWritingRulesSuffix。
写文章规则提示词:
写文章。严格按以下规则成稿。
【材料】用户提供:本场聊天记录/纪要/妙记要点、场次号、主题短句;写作前先按规范阅读飞书运营报表,把报表上有的时长、场观、进房等具体数字写进开篇(只写一次)。
【人称与禁用】叙述全程用「我」,禁止「房主」。禁止「这边」「那边」。禁止「回答说,……」——问句后直接接我的陈述。语气偏强势:干脆、笃定,少「可能/也许」。「卡若」每篇最多 1 次,可完全不出现。
【刺点三对齐】正文第一行标题「# 第X场短句」、磁盘文件名、开篇刺激性金句三者同一刺点不写空泛抒情。
【结构与比例】
- 默认单场 采用「2026 每日派对干货 / 派对体」:三块约 40% / 20%30% / 余下约 30%块与块之间必须有过渡口语每一块结束必须有一段「段末小结」单独成段3050 个汉字一句,禁止出现「干货」等标签字。
【开篇】先有冲突或世俗金句,再切入场景。固定数据句式(报表有则写具体数,没有的项不写):「第 X 场X 分钟;场观 XXXXX进房 XXX 人。」不写「约」、不写「见报表」。时长/场观/进房仅在开篇出现一次。
【中段】推进用时间线或事件线多写对话「X 号问」「有人问」);我的话直接陈述。约在全文 50% 处插入一段浓缩要点 36 条2040 字/条),前后用 --- 分隔,条目中禁止「干货」二字。
【分享句】约在 20% 处一句、结尾一句,各 ≤50 字,紧扣本节,禁止「干货:」字样。
【强调·派对体成稿必选】Markdown **加粗**:至少 2 句完整立场/结论句(分布在不同段落);至少 3 个不同的重点关键词加粗。禁止堆砌无意义加粗。
【结尾】升华主题派对体可有文末小结36 条短金句)+ 总结段落 + 一句收尾金句。
【字数与真实性】小节 30005000 字;忠于来源不改原意;所有数字必须有依据、写具体算法/金额/人次等Soul 侧常用口径可参考规范中的曝光/进房/成本写法。
【隐晦植入】文中自然散布 24 处联系管理、做切片、做副业相关一句不单段喊话随机10%加率不用每篇文章都加
【版式强制】一句一行,句间空一行;分号也拆成行;段落之间空行;大段堆砌禁止。
【交付】输出 Markdown标题一行「# 第X场…」成稿末尾按模板追加 HTML 注释自检:「我」字数/次数、本场关键字频次、关键字加粗处数、重点句/词加粗是否达标。

View File

@@ -133,6 +133,10 @@
/api/miniprogram/ckb/join
/api/miniprogram/ckb/match ← 已挂载
/api/miniprogram/upload
/api/miniprogram/my/super-stats
/api/miniprogram/super/articles ← GET 列表、POST 发布
/api/miniprogram/super/articles/:id ← GET 详情
/api/miniprogram/super/articles/generate ← POST AI 生成草稿(参考 开发文档/5、接口/API接口完整文档.md §1.3
/api/miniprogram/user/addresses, addresses/:id
/api/miniprogram/user/check-purchased, profile, purchase-status, reading-progress, update
/api/miniprogram/withdraw, withdraw/records, pending-confirm, confirm-received, confirm-info

View File

@@ -1,6 +1,6 @@
# Soul 创业派对 - 开发文档索引
> **以代码为准**:需求或实现变更时同步更新本文档与对应章节 README。最后更新2026-04-07
> **以代码为准**:需求或实现变更时同步更新本文档与对应章节 README。最后更新2026-05-09
---
@@ -99,7 +99,7 @@
| [API接口完整文档.md](./5、接口/API接口完整文档.md) | **Soul 项目 API 主真源** |
| [配置清单-完整版.md](./5、接口/配置清单-完整版.md) | 配置项清单 |
| [在线支付对接文档.md](./5、接口/在线支付对接文档.md) | 支付 |
| [接口与提现.md](./5、接口/接口与提现.md) | 提现相关接口说明 |
| [文件上传与OSS.md](./5、接口/文件上传与OSS.md) | 路由摘要,`data.storage`,互链后端详版 |
---
@@ -108,6 +108,7 @@
| 文件/路径 | 用途 |
|-----------|------|
| [README.md](./6、后端/README.md) | 本目录索引 |
| [**阿里云OSS与文件上传.md**](./6、后端/阿里云OSS与文件上传.md) | **OSS、上传策略、路由、env 兜底、`storage`、`UPLOAD_*`、自检脚本** |
| [后端架构.md](./6、后端/后端架构.md) | soul-api 架构 |
| [后端开发规范.md](./6、后端/后端开发规范.md) | 编码与路由约定 |
| [内容创建问题修复说明.md](./6、后端/内容创建问题修复说明.md) | 专项修复说明 |