Compare commits
3 Commits
devlop
...
yongpxu-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a272c92d8 | ||
|
|
4a105204cc | ||
|
|
01f9e216da |
@@ -52,9 +52,10 @@
|
||||
| 2026-04-13 | 会议:按功能同步开发文档;页面级记 miniprogram 接口与分享行为;无接口变更亦标注 | 进行中 |
|
||||
| 2026-04-14 | 吸收沉淀:逆推闭环自检写入 evolution;LOOP 需求行 | 进行中 |
|
||||
| 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
|
||||
|
||||
@@ -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 已更新
|
||||
|
||||
@@ -77,7 +77,7 @@ description: Soul 创业派对变更关联检查。miniprogram/soul-admin/soul-a
|
||||
| 找伙伴/匹配 | 匹配、展示 | match、ckb 等 miniprogram 接口 | 匹配配置、开关 |
|
||||
| 配置项 | 仅读取 | miniprogram/config 或 db/config | 配置编辑(admin/db) |
|
||||
| VIP/超级个体 | 展示列表、详情 | miniprogram/vip/members;db/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` 混表或混接口。
|
||||
|
||||
|
||||
@@ -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)。
|
||||
- 做阅读、文章等需长按复制的文本时(遵循 §9,text 加 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 接口混用。
|
||||
|
||||
@@ -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,避免与现有表职责混淆。
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"usingComponents": {
|
||||
"icon": "/components/icon/icon"
|
||||
},
|
||||
"enablePullDownRefresh": false,
|
||||
"enablePullDownRefresh": true,
|
||||
"onReachBottomDistance": 160,
|
||||
"backgroundTextStyle": "light",
|
||||
"backgroundColor": "#0b1220"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
12
soul-api/.env.super-article-ai.example
Normal file
12
soul-api/.env.super-article-ai.example
Normal 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
|
||||
@@ -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 将路径拼接到 BaseURL,path 应以 / 开头
|
||||
@@ -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)强制校验敏感配置,禁止使用默认值
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -22,15 +22,47 @@ import (
|
||||
|
||||
const aiArticleSystemPrompt = `你是「卡若创业派对」的内容创作助手,帮助超级个体写出真实、有深度的商业与创业内容文章。
|
||||
|
||||
写作要求:
|
||||
- 标题:精炼有力,≤30字,有吸引力,点明核心价值
|
||||
- 正文:600-1000字,有真实故事或案例,有观点洞察,语气自然真诚
|
||||
- 风格:像朋友分享经验,而非说教;有画面感,接地气
|
||||
- 不要使用"首先、其次、最后"等模板化结构词
|
||||
若用户提供的素材末尾附有「写文章规则提示词」或以「---」分隔的写作规范段落,你必须优先、严格按该段落成稿(体裁、字数、人称、版式、Markdown 交付与自检等)。
|
||||
|
||||
严格输出 JSON,不要有任何额外文字:
|
||||
输出封装(始终遵守):只输出一段合法 JSON,不要有任何 JSON 外的文字。title 为主标题纯文本(可与正文首行 Markdown 标题语义一致,但不要带「#」前缀);content 为正文全文,可为 Markdown(含 **加粗**、分段、---、HTML 注释等),字符串内需合法 JSON 转义。
|
||||
|
||||
若用户未附带上述写作规范段落,则默认:标题精炼有力≤30字;正文约600~1000字;语气自然真诚,像朋友分享经验;不要使用"首先、其次、最后"等模板化结构词。
|
||||
|
||||
严格输出 JSON:
|
||||
{"title":"文章标题","content":"文章正文"}`
|
||||
|
||||
// superArticleWritingRulesSuffix 在服务端拼接到用户素材末尾(与 开发文档/ai开发文档.txt 写作规则段落保持一致)。
|
||||
const superArticleWritingRulesSuffix = `写文章规则提示词:
|
||||
|
||||
写文章。严格按以下规则成稿。
|
||||
|
||||
【材料】用户提供:本场聊天记录/纪要/妙记要点、场次号、主题短句;写作前先按规范阅读飞书运营报表,把报表上有的时长、场观、进房等具体数字写进开篇(只写一次)。
|
||||
|
||||
【人称与禁用】叙述全程用「我」,禁止「房主」。禁止「这边」「那边」。禁止「回答说,……」——问句后直接接我的陈述。语气偏强势:干脆、笃定,少「可能/也许」。「卡若」每篇最多 1 次,可完全不出现。
|
||||
|
||||
【刺点三对齐】正文第一行标题「# 第X场|短句」、磁盘文件名、开篇刺激性金句三者同一刺点;不写空泛抒情。
|
||||
|
||||
【结构与比例】
|
||||
- 默认单场 采用「2026 每日派对干货 / 派对体」:三块约 40% / 20%~30% / 余下约 30%;块与块之间必须有过渡口语;每一块结束必须有一段「段末小结」单独成段,30~50 个汉字一句,禁止出现「干货」等标签字。
|
||||
|
||||
【开篇】先有冲突或世俗金句,再切入场景。固定数据句式(报表有则写具体数,没有的项不写):「第 X 场,X 分钟;场观 XXXXX,进房 XXX 人。」不写「约」、不写「见报表」。时长/场观/进房仅在开篇出现一次。
|
||||
|
||||
【中段】推进用时间线或事件线;多写对话(「X 号问」「有人问」);我的话直接陈述。约在全文 50% 处插入一段浓缩要点 3~6 条(20~40 字/条),前后用 --- 分隔,条目中禁止「干货」二字。
|
||||
|
||||
【分享句】约在 20% 处一句、结尾一句,各 ≤50 字,紧扣本节,禁止「干货:」字样。
|
||||
|
||||
【强调·派对体成稿必选】Markdown **加粗**:至少 2 句完整立场/结论句(分布在不同段落);至少 3 个不同的重点关键词加粗。禁止堆砌无意义加粗。
|
||||
|
||||
【结尾】升华主题;派对体可有文末小结(3~6 条短金句)+ 总结段落 + 一句收尾金句。
|
||||
|
||||
【字数与真实性】小节 3000~5000 字;忠于来源不改原意;所有数字必须有依据、写具体算法/金额/人次等,Soul 侧常用口径可参考规范中的曝光/进房/成本写法。
|
||||
|
||||
【隐晦植入】文中自然散布 2~4 处:联系管理、做切片、做副业相关一句,不单段喊话,随机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 content(text + 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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=谁)”归属。
|
||||
|
||||
---
|
||||
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) | 提现与相关接口 |
|
||||
|
||||
23
开发文档/5、接口/文件上传与OSS.md
Normal file
23
开发文档/5、接口/文件上传与OSS.md
Normal 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)
|
||||
@@ -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)。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库
|
||||
|
||||
@@ -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) | 管理端内容页对比 |
|
||||
|
||||
86
开发文档/6、后端/阿里云OSS与文件上传.md
Normal file
86
开发文档/6、后端/阿里云OSS与文件上传.md
Normal 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)
|
||||
50
开发文档/ai开发文档.txt
Normal file
50
开发文档/ai开发文档.txt
Normal 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%;块与块之间必须有过渡口语;每一块结束必须有一段「段末小结」单独成段,30~50 个汉字一句,禁止出现「干货」等标签字。
|
||||
|
||||
【开篇】先有冲突或世俗金句,再切入场景。固定数据句式(报表有则写具体数,没有的项不写):「第 X 场,X 分钟;场观 XXXXX,进房 XXX 人。」不写「约」、不写「见报表」。时长/场观/进房仅在开篇出现一次。
|
||||
|
||||
【中段】推进用时间线或事件线;多写对话(「X 号问」「有人问」);我的话直接陈述。约在全文 50% 处插入一段浓缩要点 3~6 条(20~40 字/条),前后用 --- 分隔,条目中禁止「干货」二字。
|
||||
|
||||
【分享句】约在 20% 处一句、结尾一句,各 ≤50 字,紧扣本节,禁止「干货:」字样。
|
||||
|
||||
【强调·派对体成稿必选】Markdown **加粗**:至少 2 句完整立场/结论句(分布在不同段落);至少 3 个不同的重点关键词加粗。禁止堆砌无意义加粗。
|
||||
|
||||
【结尾】升华主题;派对体可有文末小结(3~6 条短金句)+ 总结段落 + 一句收尾金句。
|
||||
|
||||
【字数与真实性】小节 3000~5000 字;忠于来源不改原意;所有数字必须有依据、写具体算法/金额/人次等,Soul 侧常用口径可参考规范中的曝光/进房/成本写法。
|
||||
|
||||
【隐晦植入】文中自然散布 2~4 处:联系管理、做切片、做副业相关一句,不单段喊话,随机10%加率不用每篇文章都加
|
||||
|
||||
【版式强制】一句一行,句间空一行;分号也拆成行;段落之间空行;大段堆砌禁止。
|
||||
|
||||
【交付】输出 Markdown;标题一行「# 第X场|…」;成稿末尾按模板追加 HTML 注释自检:「我」字数/次数、本场关键字频次、关键字加粗处数、重点句/词加粗是否达标。
|
||||
@@ -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
|
||||
|
||||
@@ -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) | 专项修复说明 |
|
||||
|
||||
Reference in New Issue
Block a user