2 Commits 5afe2dd3dd ... c898c0c887

Autor SHA1 Mensaje Fecha
  bob c898c0c887 docs: AGENTS.md 新增 C-23~C-25 组织页/落地页/改密变更记录 hace 1 mes
  bob 2026d281bc feat: 广告落地页 /lp 引流入口与线索后台、组织页完善、取消老师首次登录强制改密 hace 1 mes

+ 44 - 0
AGENTS.md

@@ -504,3 +504,47 @@
 - 未确认与风险:本机此前未安装 pnpm,已通过 corepack 临时激活;后续开发/CI 需确保 pnpm 可用(建议 `corepack enable` 或全局安装 pnpm)。`node_modules` 当前仍为 npm 格式,首次 `pnpm install` 会将其转为 pnpm 符号链接结构,属本地可逆变更。
 - 后续修改提示:所有安装、升级依赖必须走 pnpm;提交前确认 `pnpm-lock.yaml` 已更新且 `package-lock.json` 不出现在 `git status`。
 - 关联:D-010。
+
+### C-20260923-23|机构主页管理员点击课程进入详情
+
+- 日期:2026-09-23
+- 类型:功能
+- 需求与原因:组织管理员需要从机构主页课程进度表直接打开课程详情。
+- 修改范围:`src/pages/OrganizationPage.tsx` 将管理员视角的课程名称改为课程详情链接;点击时切换到该机构工作区。
+- 保留/新增判断:遵守 D-001;沿用现有课程详情接口的服务端权限校验,仅向机构管理员提供此入口。
+- 功能确认状态:部分确认
+- 验证证据:确认 `/courses/:id` 路由和课程详情接口允许所属组织管理员访问;`npm run check`、`npm run build`、`git diff --check` 通过。
+- 未确认与风险:未使用已登录机构管理员在浏览器实际点击验证,也未部署公网。
+- 后续修改提示:若普通组织成员也需进入课程详情,先调整并验证服务端课程访问权限,再开放入口。
+- 关联:D-001、C-20260923-04。
+
+### C-20260923-24|广告落地页 /lp:数字人课程制作引流入口与线索后台
+
+- 日期:2026-09-23
+- 类型:功能 / 数据库 / 前端
+- 需求与原因:需要在外部广告位投放 banner 引流,落地页须与主站隔离、互不干扰;以留下联系方式为主要目标、注册降为可选,并用广告语言写清数字人课程制作服务内容。用户确认:独立构建入口、留资为主 + 注册折叠选填、线索入库并做后台管理页、复用现有微信群二维码,微信号对外展示 bob-yuxinyang,价格不外露,暂不放样片。
+- 修改范围:
+  1. 新增独立入口 `landing.html` 与 `src/landing/`(`main.tsx`、`LandingPage.tsx`、`landing.css`);`vite.config.ts` 改为双入口构建;生产服务端新增 `/lp`、`/lp/*` 显式路由,返回 `dist/landing.html`。
+  2. `server/schema.sql` 新增 `lead_requests` 表(姓名/手机/微信/需求类型/体量/描述/来源/状态/备注/关联用户/时间)。
+  3. `server/index.ts` 新增 `POST /api/public/leads`(公开提交、按 IP 60 秒限流、同手机号 24 小时内合并更新、可选密码注册)、`GET /api/admin/leads`、`PATCH /api/admin/leads/:id`(均 adminAuth)。
+  4. 前端新增 `src/pages/admin/AdminLeads.tsx`、`/admin/leads` 路由与「落地页线索」导航项;`src/api.ts` 增加 `admin.listLeads` / `admin.updateLead`;`src/types.ts` 增加 `Lead` / `LeadStatus`;`src/styles.css` 仅新增 `.status-filter-select` 与 `.lead-message` 两个类。
+  5. README 补充落地页说明。
+- 保留/新增判断:保留 D-001 至 D-010。新增:广告落地页必须与主站物理隔离(独立 HTML 入口 + 独立 CSS,不加载主站认证/路由上下文,样式统一 `lp-` 前缀);线索属公开写入数据,必须限流并在服务端剥离 HTML;注册只能由用户显式填写密码触发,不得自动建号;同手机号 24 小时内重复提交按更新处理,避免线索重复。
+- 功能确认状态:部分确认
+- 验证证据:`npm run check`、`npm run build` 通过,产物含独立 `dist/landing.html` 与 `landing-*.css|js`,主站 `dist/index.html` 不引用任何 landing 资源(grep 计数 0)。以 `NODE_ENV=production` 启动本地服务(端口 3011)实测:`/lp` 与 `/lp/anything` 返回 200 且标题为「数字人课程制作 · 星痕智能课程工坊」,`/dashboard` 仍走主站 SPA 兜底;`POST /api/public/leads` 正常 201、非法手机号 400、缺微信号 400、同 IP 60 秒内 429、带密码提交返回 token 且用该 token 调 `/api/me` 返回 200(姓名与微信号已写入)、同手机号重复注册 409;同手机号 24 小时内再次提交经 SQL 核对仍为 1 行且字段为最新值;`<script>alert(1)</script><b>加粗</b>` 落库为「加粗正常内容」。`GET /api/admin/leads` 管理员 200 且带 userName,未登录与普通用户 token 均 401,`PATCH` 更新状态与备注返回 200。测试数据已清理(3 个测试账号、5 条线索、1 条临时管理员会话)。
+- 未确认与风险:未做公网部署验证(D-009 的 HTTPS 与真实域名层未覆盖);未做浏览器真机目视核对,桌面与窄屏仅按 CSS 断点编写;`/lp` 依赖 `NODE_ENV=production` 分支,本地开发需访问 `/landing.html`;`lead_requests` 建表仅在本地 dev 库执行过,未在生产库验证(语句为 CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS,可重复执行,但未经生产库实测);本次未新增依赖,`pnpm-lock.yaml` 无需变更,验证使用的是 npm scripts 而非 pnpm。
+- 后续修改提示:改落地页只动 `src/landing/` 与 `landing.css`,禁止 import 主站 `styles.css` 或 `src/api.ts`;新增线索字段须同步 `leadSchema`、`mapLead`、`Lead` 类型与后台表格;投放链接建议带 `?from=` 或 `utm_source` 以保留渠道归因;对外微信号与二维码位于 `LandingPage.tsx` 的 `WECHAT_ID` 常量与 `src/assets/contact-group-qrcode.jpg`。
+- 关联:无。
+
+### C-20260923-25|修复现场创建老师报错并取消首次登录强制改密
+
+- 日期:2026-09-23
+- 类型:修复 / 功能 / 数据库
+- 需求与原因:机构管理员创建老师后账号已写入,却因异步提交后读取已清空的 React 事件 `currentTarget` 而显示 `reset` 报错;用户要求老师首次登录直接进入工作区。
+- 修改范围:`src/pages/OrganizationManage.tsx` 在异步请求前保存表单元素,成功后重置表单并更新提示;`server/organizations.ts` 创建老师时将强制改密标记设为 `FALSE`;`server/index.ts` 与 `src/App.tsx` 移除旧强制改密拦截;`src/pages/Profile.tsx` 保留自愿改密入口但移除强制提示;`server/schema.sql` 幂等清除已有账号的旧强制改密标记;同步更新 `README.md`。
+- 保留/新增判断:本条替代 D-006 的“首次登录必须修改密码”规则。现场创建账号仍使用初始密码 `888888`,首次登录可直接进入工作区;用户可在账户安全自行改密。服务端仍须校验登录身份及组织权限(D-001)。
+- 功能确认状态:部分确认
+- 验证证据:代码检查确认创建流程在异步边界前保存表单引用;`npm run check`、`npm run build`、`git diff --check` 通过。迁移语句仅更新标记为 `TRUE` 的账号,重复执行不再产生变化。
+- 未确认与风险:未在隔离数据库中实际执行迁移及新老师登录全流程;未在公网部署或验收。固定初始密码仍需由机构管理员妥善交付,账号可自行改密。
+- 后续修改提示:不得恢复新老师首次登录强制改密,除非用户提出新的规则;涉及登录流程应同时核对前端路由和服务端鉴权。
+- 关联:替代 D-006;C-20260923-06。

+ 9 - 1
README.md

@@ -25,10 +25,18 @@ Server 启动时会自动创建所需的数据表。
 - 平台管理员可停用并恢复组织。停用期间停止新成员加入和新建组织课程,历史课程、成员与账单保留。
 - 组织管理员在 `/organizations/<组织网址标识>/manage` 审批加入申请、创建老师账号、生成免审批注册链接、管理组织课程并查看完整账单。
 - 普通成员在工作台切换个人或组织工作区;组织页面只显示课程列表、进度和人员名单。成员只能查看自己课程产生的组织积分消耗。
-- 组织管理员现场创建老师时只需手机号和姓名;初始密码为 `888888`,首次登录须修改密码。
+- 组织管理员现场创建老师时只需手机号和姓名;初始密码为 `888888`,首次登录可直接进入工作区,之后可在账户安全中自行修改密码。
 - 上传素材与交付文件通过鉴权接口读取,原 `/uploads/` 公开文件路径不再提供文件。
 - 已有课程默认保留在个人工作区,已有个人积分保留并转换为个人流水。旧的用户“单位”资料不再参与组织关系。
 
+## 广告落地页
+
+- 独立入口:`landing.html` + `src/landing/`,自带独立样式,与主站路由、全局样式和登录态完全隔离。
+- 生产访问:`/lp`(服务端显式路由,返回 `dist/landing.html`);本地开发访问 `http://localhost:5173/landing.html`。
+- 页面提交的线索写入 `lead_requests` 表,管理员在 `/admin/leads` 跟进,状态包括:待联系 / 已联系 / 已成交 / 无效,可添加跟进备注。
+- 投放时可在链接后带来源参数,例如 `/lp?from=wechat-mp` 或标准 `utm_source` / `utm_campaign`,会随线索一起入库,便于对比渠道效果。
+- 修改落地页请只改 `src/landing/` 与 `landing.css`,不要引入主站的 `styles.css` 或 `api.ts`。
+
 ## 公网部署
 
 推荐在带持久磁盘的 Linux 云服务器运行:

+ 18 - 0
landing.html

@@ -0,0 +1,18 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="theme-color" content="#f7f8fb" />
+    <meta name="robots" content="index,follow" />
+    <title>数字人课程制作 · 星痕智能课程工坊</title>
+    <meta
+      name="description"
+      content="不用拍摄、不用剪辑:提供讲稿或 PPT,由数字人讲师出镜、原声复刻配音,批量产出 1080P 视频课程。留下需求,专属顾问当天联系。"
+    />
+  </head>
+  <body>
+    <div id="lp-root"></div>
+    <script type="module" src="/src/landing/main.tsx"></script>
+  </body>
+</html>

+ 159 - 3
server/index.ts

@@ -116,9 +116,6 @@ async function auth(req: FastifyRequest, reply: FastifyReply) {
   )
   if (!rows[0]) return reply.code(401).send({ message: '登录已过期,请重新登录' })
   ;(req as any).user = rows[0]
-  if (rows[0].must_change_password && !['/api/me', '/api/me/password', '/api/auth/logout'].includes(req.url.split('?')[0])) {
-    return reply.code(403).send({ message: '请先修改初始密码' })
-  }
 }
 
 async function adminAuth(req: FastifyRequest, reply: FastifyReply) {
@@ -1312,10 +1309,169 @@ app.patch('/api/admin/users/:id/role', { preHandler: adminAuth }, async (req, re
   return reply.code(403).send({ message: '平台管理员与普通用户不能互相转化' })
 })
 
+// ==================== 广告落地页线索(公开提交 / 管理员跟进) ====================
+
+const LEAD_STATUSES = ['PENDING', 'CONTACTED', 'WON', 'INVALID'] as const
+const LEAD_WINDOW_MS = 60_000
+const leadSubmitWindow = new Map<string, number>()
+
+const leadSchema = z.object({
+  name: z.string().trim().min(1, '请填写您的姓名').max(80, '姓名不能超过 80 个字'),
+  phone: z.string().trim().regex(/^1\d{10}$/, '请输入正确的手机号'),
+  wechat: z.string().trim().min(1, '请填写微信号,方便顾问联系您').max(80, '微信号不能超过 80 个字'),
+  needType: z.string().trim().max(40).default('OTHER'),
+  courseScale: z.string().trim().max(40).default(''),
+  message: z.string().trim().max(1000, '需求描述不能超过 1000 字').default(''),
+  source: z.string().trim().max(120).default(''),
+  password: z.string().min(6, '密码至少 6 位').max(72).optional()
+})
+
+function mapLead(row: any) {
+  return {
+    id: row.id,
+    name: row.name,
+    phone: row.phone,
+    wechat: row.wechat,
+    needType: row.need_type,
+    courseScale: row.course_scale,
+    message: row.message,
+    source: row.source,
+    status: row.status,
+    note: row.note,
+    userId: row.user_id,
+    userName: row.user_name || '',
+    createdAt: row.created_at,
+    updatedAt: row.updated_at
+  }
+}
+
+// 公开提交:无需登录。用于广告落地页收集联系方式与简单需求。
+app.post('/api/public/leads', async (req, reply) => {
+  const ip = (String(req.headers['x-forwarded-for'] || '').split(',')[0] || req.ip || 'unknown').trim()
+  const now = Date.now()
+  if (leadSubmitWindow.size > 2000) {
+    for (const [key, at] of leadSubmitWindow) if (now - at > LEAD_WINDOW_MS) leadSubmitWindow.delete(key)
+  }
+  const last = leadSubmitWindow.get(ip)
+  if (last && now - last < LEAD_WINDOW_MS) return reply.code(429).send({ message: '提交过于频繁,请稍后再试' })
+
+  const p = leadSchema.safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: p.error.issues[0].message })
+
+  // 可选注册:填了密码才创建账号,不填只留线索。
+  let newUserId: string | null = null
+  let token: string | null = null
+  if (p.data.password) {
+    if ((await pool.query('SELECT 1 FROM users WHERE phone=$1', [p.data.phone])).rows[0]) {
+      return reply.code(409).send({ message: '该手机号已注册,请直接登录' })
+    }
+    if ((await pool.query('SELECT 1 FROM platform_admins WHERE phone=$1', [p.data.phone])).rows[0]) {
+      return reply.code(409).send({ message: '该手机号不可用于注册普通用户' })
+    }
+    newUserId = randomUUID()
+    await pool.query(
+      'INSERT INTO users(id, phone, password_hash, role, contact_name, wechat, points_balance) VALUES($1, $2, $3, $4, $5, $6, 10000)',
+      [newUserId, p.data.phone, await hashPassword(p.data.password), 'USER', p.data.name, p.data.wechat]
+    )
+    token = await session(newUserId)
+  }
+
+  const client = await pool.connect()
+  try {
+    await client.query('BEGIN')
+    let userId = newUserId
+    if (!userId) {
+      const existing = await client.query('SELECT id FROM users WHERE phone=$1', [p.data.phone])
+      if (existing.rows[0]) userId = existing.rows[0].id
+    }
+    const message = sanitizeHtml(p.data.message, { allowedTags: [], allowedAttributes: {} })
+    const recent = await client.query(
+      `SELECT id FROM lead_requests WHERE phone=$1 AND created_at > NOW() - INTERVAL '24 hours' ORDER BY created_at DESC LIMIT 1`,
+      [p.data.phone]
+    )
+    if (recent.rows[0]) {
+      // 同一手机号 24 小时内重复提交视为补充需求,更新而非新增,避免线索重复。
+      await client.query(
+        `UPDATE lead_requests SET name=$1, wechat=$2, need_type=$3, course_scale=$4, message=$5, source=$6,
+         user_id = COALESCE($7, user_id), updated_at = NOW() WHERE id=$8`,
+        [p.data.name, p.data.wechat, p.data.needType, p.data.courseScale, message, p.data.source, userId, recent.rows[0].id]
+      )
+    } else {
+      await client.query(
+        `INSERT INTO lead_requests(id, name, phone, wechat, need_type, course_scale, message, source, user_id)
+         VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+        [randomUUID(), p.data.name, p.data.phone, p.data.wechat, p.data.needType, p.data.courseScale, message, p.data.source, userId]
+      )
+    }
+    await client.query('COMMIT')
+    leadSubmitWindow.set(ip, now)
+    return reply.code(201).send({ ok: true, token })
+  } catch (error) {
+    await client.query('ROLLBACK')
+    throw error
+  } finally {
+    client.release()
+  }
+})
+
+app.get('/api/admin/leads', { preHandler: adminAuth }, async (req) => {
+  const { status, keyword } = (req.query || {}) as { status?: string; keyword?: string }
+  const where: string[] = []
+  const params: any[] = []
+  if (status && (LEAD_STATUSES as readonly string[]).includes(status)) {
+    params.push(status)
+    where.push(`l.status = $${params.length}`)
+  }
+  if (keyword && keyword.trim()) {
+    params.push(`%${keyword.trim()}%`)
+    where.push(`(l.name ILIKE $${params.length} OR l.phone ILIKE $${params.length} OR l.wechat ILIKE $${params.length} OR l.message ILIKE $${params.length})`)
+  }
+  const { rows } = await pool.query(
+    `SELECT l.*, u.contact_name AS user_name
+     FROM lead_requests l LEFT JOIN users u ON u.id = l.user_id
+     ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
+     ORDER BY l.created_at DESC LIMIT 500`,
+    params
+  )
+  return { leads: rows.map(mapLead) }
+})
+
+app.patch('/api/admin/leads/:id', { preHandler: adminAuth }, async (req, reply) => {
+  const { id } = req.params as any
+  const p = z.object({
+    status: z.enum(LEAD_STATUSES).optional(),
+    note: z.string().max(1000, '备注不能超过 1000 字').optional()
+  }).safeParse(req.body)
+  if (!p.success) return reply.code(400).send({ message: '线索状态或备注格式不正确' })
+
+  const sets = ['updated_at = NOW()']
+  const params: any[] = []
+  if (p.data.status) {
+    params.push(p.data.status)
+    sets.push(`status = $${params.length}`)
+  }
+  if (p.data.note !== undefined) {
+    params.push(p.data.note)
+    sets.push(`note = $${params.length}`)
+  }
+  params.push(id)
+  const { rows } = await pool.query(
+    `UPDATE lead_requests SET ${sets.join(', ')} WHERE id = $${params.length}
+     RETURNING *, (SELECT contact_name FROM users WHERE id = lead_requests.user_id) AS user_name`,
+    params
+  )
+  if (!rows[0]) return reply.code(404).send({ message: '线索不存在' })
+  return { lead: mapLead(rows[0]) }
+})
+
 // ==================== 静态托管与错误处理 ====================
 
 if (process.env.NODE_ENV === 'production') {
   const dist = resolve('./dist')
+  // 广告落地页独立入口,不与主站 SPA 兜底路由混用。
+  const sendLanding = async (_req: FastifyRequest, reply: FastifyReply) => reply.sendFile('landing.html', dist)
+  app.get('/lp', sendLanding)
+  app.get('/lp/*', sendLanding)
   await app.register(staticPlugin, { root: dist, prefix: '/', wildcard: false, decorateReply: false })
   app.setNotFoundHandler((req, reply) =>
     req.url.startsWith('/api/') || req.url.startsWith('/uploads/')

+ 16 - 1
server/organizations.ts

@@ -139,7 +139,7 @@ export function registerOrganizations(app: FastifyInstance, { pool, auth, adminA
       await client.query('BEGIN')
       const userId = randomUUID()
       const created = await client.query(`INSERT INTO users(id,phone,password_hash,role,contact_name,points_balance,must_change_password)
-        VALUES($1,$2,$3,'USER',$4,10000,TRUE) RETURNING *`,
+        VALUES($1,$2,$3,'USER',$4,10000,FALSE) RETURNING *`,
         [userId, parsed.data.phone, await hashPassword('888888'), parsed.data.contactName])
       await client.query('INSERT INTO organization_members(organization_id,user_id) VALUES($1,$2)', [id, userId])
       await client.query('COMMIT')
@@ -164,6 +164,21 @@ export function registerOrganizations(app: FastifyInstance, { pool, auth, adminA
     return { user: mapUser(rows[0]) }
   })
 
+  app.delete('/api/organizations/:id/members/:userId', { preHandler: auth }, async (req, reply) => {
+    const id = params(req).id
+    if (!(await manager(req, reply, id))) return
+    const targetUserId = params(req).userId
+    if (targetUserId === uid(req)) {
+      return reply.code(400).send({ message: '不能将自己移出组织' })
+    }
+    const member = await membership(id, targetUserId)
+    if (!member) return reply.code(404).send({ message: '组织成员不存在' })
+    const { rows: [targetUser] } = await pool.query('SELECT contact_name, phone FROM users WHERE id=$1', [targetUserId])
+    await pool.query('DELETE FROM organization_members WHERE organization_id=$1 AND user_id=$2', [id, targetUserId])
+    await event(id, uid(req), 'ORG_MEMBER_REMOVED', { name: targetUser?.contact_name, phone: targetUser?.phone }, targetUserId)
+    return { ok: true }
+  })
+
   app.post('/api/organizations/:id/invite', { preHandler: auth }, async (req, reply) => {
     const id = params(req).id
     if (!(await manager(req, reply, id))) return

+ 23 - 0
server/schema.sql

@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS users (
 ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'USER';
 ALTER TABLE users ADD COLUMN IF NOT EXISTS points_balance INT NOT NULL DEFAULT 10000;
 ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE;
+UPDATE users SET must_change_password = FALSE WHERE must_change_password = TRUE;
 ALTER TABLE users ALTER COLUMN points_balance SET DEFAULT 10000;
 
 CREATE TABLE IF NOT EXISTS platform_admins (
@@ -364,3 +365,25 @@ UPDATE points_ledger l SET course_name_snapshot=c.name FROM courses c
 WHERE l.course_id=c.id AND l.course_name_snapshot IS NULL;
 
 ALTER TABLE digital_instructor_events ADD COLUMN IF NOT EXISTS admin_actor_id UUID REFERENCES platform_admins(id) ON DELETE SET NULL;
+
+-- 广告落地页(/lp)收集的课程咨询线索。无需登录即可写入,由管理员在后台跟进。
+CREATE TABLE IF NOT EXISTS lead_requests (
+  id UUID PRIMARY KEY,
+  name VARCHAR(80) NOT NULL,
+  phone VARCHAR(20) NOT NULL,
+  wechat VARCHAR(80) NOT NULL DEFAULT '',
+  need_type VARCHAR(40) NOT NULL DEFAULT 'OTHER',
+  course_scale VARCHAR(40) NOT NULL DEFAULT '',
+  message TEXT NOT NULL DEFAULT '',
+  source VARCHAR(120) NOT NULL DEFAULT '',
+  user_id UUID REFERENCES users(id) ON DELETE SET NULL,
+  status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+  note TEXT NOT NULL DEFAULT '',
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PENDING';
+ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '';
+ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS source VARCHAR(120) NOT NULL DEFAULT '';
+CREATE INDEX IF NOT EXISTS idx_lead_requests_created ON lead_requests(created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_lead_requests_phone ON lead_requests(phone);

+ 2 - 5
src/App.tsx

@@ -18,6 +18,7 @@ import { OrganizationPage, InviteJoinPage } from './pages/OrganizationPage'
 import { OrganizationManage } from './pages/OrganizationManage'
 import { DigitalInstructorsPage } from './pages/DigitalInstructorsPage'
 import { AdminDigitalInstructors } from './pages/admin/AdminDigitalInstructors'
+import { AdminLeads } from './pages/admin/AdminLeads'
 import { DigitalInstructorEditorPage } from './pages/DigitalInstructorEditorPage'
 import { useAuth } from './AuthContext'
 
@@ -26,11 +27,6 @@ export default function App() {
 
   if (loading) return <div className="center-loading">正在加载星痕工坊…</div>
 
-  if (user?.mustChangePassword) return <Routes>
-    <Route element={<Layout />}><Route path="profile" element={<Profile />} /></Route>
-    <Route path="*" element={<Navigate to="/profile?tab=security" replace />} />
-  </Routes>
-
   return (
     <Routes>
       {/* ================= 管理员专属体系 (/admin/*) ================= */}
@@ -59,6 +55,7 @@ export default function App() {
         <Route path="users" element={<AdminUsers />} />
         <Route path="organizations" element={<AdminOrganizations />} />
         <Route path="digital-instructors" element={<AdminDigitalInstructors />} />
+        <Route path="leads" element={<AdminLeads />} />
         <Route path="digital-instructors/:id/edit" element={<DigitalInstructorEditorPage admin />} />
         <Route path="organizations/:id" element={<AdminOrganizationDetail />} />
         <Route path="*" element={<Navigate to="/admin" replace />} />

+ 18 - 1
src/api.ts

@@ -1,4 +1,4 @@
-import type { AdminStats, Course, CourseStatus, CourseTemplate, DigitalInstructor, Organization, PointsTransaction, User } from './types'
+import type { AdminStats, Course, CourseStatus, CourseTemplate, DigitalInstructor, Lead, LeadStatus, Organization, PointsTransaction, User } from './types'
 
 const token = () => localStorage.getItem('xinghen_token')
 
@@ -110,6 +110,9 @@ export const api = {
     updateMemberName: (id: string, userId: string, contactName: string) => request<{ user: User }>(`/organizations/${id}/members/${userId}`, {
       method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contactName })
     }),
+    removeMember: (id: string, userId: string) => request<{ ok: boolean }>(`/organizations/${id}/members/${userId}`, {
+      method: 'DELETE'
+    }),
     rotateInvite: (id: string) => request<{ token: string }>(`/organizations/${id}/invite`, { method: 'POST' }),
     disableInvite: (id: string) => request<{ ok: boolean }>(`/organizations/${id}/invite`, { method: 'DELETE' }),
     points: async (id: string) => {
@@ -314,6 +317,20 @@ export const api = {
         method: 'PATCH',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ password })
+      }),
+    // 广告落地页线索
+    listLeads: (params?: { status?: LeadStatus | ''; keyword?: string }) => {
+      const q = new URLSearchParams()
+      if (params?.status) q.set('status', params.status)
+      if (params?.keyword) q.set('keyword', params.keyword)
+      const qs = q.toString() ? `?${q.toString()}` : ''
+      return request<{ leads: Lead[] }>(`/admin/leads${qs}`)
+    },
+    updateLead: (id: string, body: { status?: LeadStatus; note?: string }) =>
+      request<{ lead: Lead }>(`/admin/leads/${id}`, {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(body)
       })
   }
 }

+ 542 - 0
src/landing/LandingPage.tsx

@@ -0,0 +1,542 @@
+import { useEffect, useState, type FormEvent } from 'react'
+import {
+  ArrowRight,
+  Check,
+  CheckCircle2,
+  Clapperboard,
+  Copy,
+  FileText,
+  Layers3,
+  Languages,
+  Mic,
+  QrCode,
+  Sparkles,
+  Users2,
+  Video,
+} from 'lucide-react'
+import contactQrCode from '../assets/contact-group-qrcode.jpg'
+
+const WECHAT_ID = 'bob-yuxinyang'
+
+const NEED_TYPES = [
+  { value: 'DIGITAL_HUMAN', label: '数字人讲师出镜视频' },
+  { value: 'VOICE_CLONE', label: '本人声音复刻配音' },
+  { value: 'SCRIPT_TO_COURSE', label: '讲稿 / PPT 转分集课程' },
+  { value: 'ENTERPRISE_TRAINING', label: '企业内训批量课程' },
+  { value: 'OTHER', label: '其他定制需求' },
+]
+
+const SCALES = [
+  { value: '', label: '暂不确定' },
+  { value: 'LT10', label: '10 讲以内' },
+  { value: '10_30', label: '10 - 30 讲' },
+  { value: '30_100', label: '30 - 100 讲' },
+  { value: 'GT100', label: '100 讲以上' },
+]
+
+const services = [
+  {
+    icon: Video,
+    title: '数字人讲师出镜',
+    text: '一张照片即可生成专属讲师形象,口型同步、表情自然,近景与中景自动切换,不用进棚拍摄。',
+  },
+  {
+    icon: Mic,
+    title: '原声复刻配音',
+    text: '几分钟录音复刻本人音色,讲稿直接转成配音,语气与停顿可按课程风格调整,不用反复返录。',
+  },
+  {
+    icon: FileText,
+    title: '讲稿 / PPT 转分集',
+    text: '按大纲自动拆分课时,逐集生成画面与讲稿,一次确认批量出片,课程更新只改讲稿即可。',
+  },
+  {
+    icon: Clapperboard,
+    title: '字幕与片头包装',
+    text: '自动字幕校对、片头片尾、背景音乐、章节卡与封面图,交付即是可直接发布的成片。',
+  },
+  {
+    icon: Languages,
+    title: '多形象 / 多语种',
+    text: '同一门课可出多个讲师版本,支持多语种翻配,一套内容覆盖不同学员群体。',
+  },
+  {
+    icon: Users2,
+    title: '企业内训批量交付',
+    text: '党建、职教、岗位培训、产品课程统一模板量产,进度与成片在工作台可追溯。',
+  },
+]
+
+const steps = [
+  { number: '01', title: '给讲稿或课件', text: '把大纲、PPT 或逐字稿交给我们,不用自己写脚本。' },
+  { number: '02', title: '定形象与声音', text: '确认数字人形象与配音音色,先看样片再批量生产。' },
+  { number: '03', title: '批量生成成片', text: '按课时逐集生成,字幕、包装、封面一次做完。' },
+  { number: '04', title: '验收与交付', text: '在线确认成片与修改意见,交付 MP4 与配套素材。' },
+]
+
+const promises = [
+  { title: '先看样片再下单', text: '形象与音色确认后再批量生产,避免做完才发现不合适。' },
+  { title: '排期可协商', text: '按课程量与交付节奏协商排期,急单可单独沟通。' },
+  { title: '成果可追溯', text: '课程、素材与成片统一归档,随时回看历史版本。' },
+]
+
+function BrandMark({ compact = false }: { compact?: boolean }) {
+  return (
+    <div className={`lp-brand ${compact ? 'lp-brand--compact' : ''}`} aria-label="星痕智能课程工坊">
+      <span className="lp-brand-mark" aria-hidden="true">
+        <svg viewBox="0 0 44 26" fill="none" xmlns="http://www.w3.org/2000/svg">
+          <defs>
+            <linearGradient id="lpGoldDot" x1="0%" y1="0%" x2="100%" y2="100%">
+              <stop offset="0%" stopColor="#deb66c" />
+              <stop offset="100%" stopColor="#b58735" />
+            </linearGradient>
+            <linearGradient id="lpArcTrail" x1="0%" y1="0%" x2="100%" y2="0%">
+              <stop offset="0%" stopColor="#8292a8" />
+              <stop offset="60%" stopColor="#8fa0b5" />
+              <stop offset="100%" stopColor="#a5b5cb" />
+            </linearGradient>
+          </defs>
+          <circle cx="8" cy="18" r="3.6" fill="url(#lpGoldDot)" />
+          <path d="M 14.2 14.2 C 20.2 7.2, 30.2 4.6, 41 10" stroke="url(#lpArcTrail)" strokeWidth="1.8" strokeLinecap="round" />
+        </svg>
+      </span>
+      <span className="lp-brand-text">
+        <strong>星痕</strong>
+        {!compact && <small>XINGHEN</small>}
+      </span>
+    </div>
+  )
+}
+
+function readSource() {
+  if (typeof window === 'undefined') return ''
+  const params = new URLSearchParams(window.location.search)
+  const raw =
+    params.get('from') ||
+    params.get('utm_source') ||
+    params.get('utm_campaign') ||
+    params.get('s') ||
+    ''
+  const campaign = params.get('utm_campaign')
+  const merged = raw && campaign && raw !== campaign ? `${raw}/${campaign}` : raw
+  return merged.trim().slice(0, 120)
+}
+
+export function LandingPage() {
+  const [source] = useState(readSource)
+  const [busy, setBusy] = useState(false)
+  const [error, setError] = useState('')
+  const [submitted, setSubmitted] = useState(false)
+  const [registered, setRegistered] = useState(false)
+  const [openAccount, setOpenAccount] = useState(false)
+  const [copied, setCopied] = useState(false)
+
+  useEffect(() => {
+    document.title = '数字人课程制作 · 星痕智能课程工坊'
+  }, [])
+
+  async function submit(e: FormEvent<HTMLFormElement>) {
+    e.preventDefault()
+    const f = new FormData(e.currentTarget)
+    const payload: Record<string, string> = {
+      name: String(f.get('name') || '').trim(),
+      phone: String(f.get('phone') || '').trim(),
+      wechat: String(f.get('wechat') || '').trim(),
+      needType: String(f.get('needType') || 'OTHER'),
+      courseScale: String(f.get('courseScale') || ''),
+      message: String(f.get('message') || '').trim(),
+      source,
+    }
+    if (openAccount) payload.password = String(f.get('password') || '')
+
+    setBusy(true)
+    setError('')
+    try {
+      const response = await fetch('/api/public/leads', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(payload),
+      })
+      const body = (await response.json().catch(() => ({}))) as { message?: string; token?: string }
+      if (!response.ok) throw new Error(body.message || '提交失败,请稍后重试')
+      if (body.token) localStorage.setItem('xinghen_token', body.token)
+      setRegistered(Boolean(body.token))
+      setSubmitted(true)
+    } catch (x) {
+      setError(x instanceof Error ? x.message : '提交失败,请稍后重试')
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  async function copyWechat() {
+    try {
+      await navigator.clipboard.writeText(WECHAT_ID)
+    } catch {
+      const textarea = document.createElement('textarea')
+      textarea.value = WECHAT_ID
+      textarea.style.position = 'fixed'
+      textarea.style.opacity = '0'
+      document.body.appendChild(textarea)
+      textarea.select()
+      document.execCommand('copy')
+      textarea.remove()
+    }
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  return (
+    <div className="lp-page">
+      <header className="lp-nav">
+        <a href="/" aria-label="星痕首页">
+          <BrandMark />
+        </a>
+        <a className="lp-nav-link" href="#lp-services">
+          能做什么
+        </a>
+        <a className="lp-nav-link" href="#lp-process">
+          怎么做
+        </a>
+        <div className="lp-nav-actions">
+          <a className="lp-nav-login" href="/login">
+            登录
+          </a>
+          <a className="lp-nav-login" href="/help">
+            帮助中心
+          </a>
+          <a className="lp-button lp-button--small" href="#lp-form">
+            留下需求
+          </a>
+        </div>
+      </header>
+
+      <main>
+        <section className="lp-hero">
+          <div className="lp-hero-copy">
+            <p className="lp-kicker">
+              <span />
+              数字人课程制作服务
+            </p>
+            <h1>
+              不用拍摄、不用剪辑,
+              <br />
+              <em>用数字人把课件做成课程。</em>
+            </h1>
+            <p className="lp-lead">
+              你提供讲稿或 PPT,我们交付数字人讲师出镜、本人音色配音、字幕包装齐全的 1080P
+              视频课程。单门试做或批量量产都可以,先看样片再决定。
+            </p>
+            <div className="lp-hero-actions">
+              <a className="lp-button" href="#lp-form">
+                留下需求,顾问当天联系 <ArrowRight size={17} />
+              </a>
+              <a className="lp-text-link" href="#lp-services">
+                先看能做什么 <span>↓</span>
+              </a>
+            </div>
+            <div className="lp-trust">
+              <span>
+                <Check size={14} /> 先看样片再下单
+              </span>
+              <span>
+                <Check size={14} /> 本人音色复刻
+              </span>
+              <span>
+                <Check size={14} /> 成片与素材统一归档
+              </span>
+            </div>
+          </div>
+
+          <div className="lp-hero-visual" aria-hidden="true">
+            <div className="lp-orbit lp-orbit-a" />
+            <div className="lp-orbit lp-orbit-b" />
+            <div className="lp-star" />
+            <div className="lp-preview">
+              <div className="lp-preview-screen">
+                <div className="lp-preview-badge">数字人讲师 · 成片</div>
+                <div className="lp-preview-figure">
+                  <i className="lp-figure-head" />
+                  <i className="lp-figure-body" />
+                </div>
+                <div className="lp-preview-progress">
+                  <i />
+                </div>
+                <div className="lp-preview-meta">
+                  <span>第 03 讲 · 课程导入</span>
+                  <strong>02:14</strong>
+                </div>
+              </div>
+              <ul className="lp-preview-list">
+                <li className="done">
+                  <span>01</span>课程创建
+                </li>
+                <li className="done">
+                  <span>02</span>素材整理
+                </li>
+                <li className="current">
+                  <span>03</span>批量生成
+                </li>
+                <li>
+                  <span>04</span>验收交付
+                </li>
+              </ul>
+            </div>
+          </div>
+        </section>
+
+        <section className="lp-section" id="lp-services">
+          <div className="lp-section-head">
+            <p className="lp-section-kicker">WHAT WE DO</p>
+            <h2>我们能帮你把什么做成课程</h2>
+            <p className="lp-section-lead">
+              从一张照片、一段录音、一份讲稿开始,剩下的制作流程交给星痕。
+            </p>
+          </div>
+          <div className="lp-service-grid">
+            {services.map(({ icon: Icon, title, text }) => (
+              <article className="lp-service-card" key={title}>
+                <span className="lp-service-icon">
+                  <Icon size={19} strokeWidth={1.7} />
+                </span>
+                <h3>{title}</h3>
+                <p>{text}</p>
+              </article>
+            ))}
+          </div>
+        </section>
+
+        <section className="lp-section lp-section--soft" id="lp-process">
+          <div className="lp-section-head">
+            <p className="lp-section-kicker">HOW IT WORKS</p>
+            <h2>四步拿到成片</h2>
+          </div>
+          <div className="lp-step-grid">
+            {steps.map(({ number, title, text }) => (
+              <article className="lp-step-card" key={number}>
+                <span className="lp-step-number">{number}</span>
+                <h3>{title}</h3>
+                <p>{text}</p>
+              </article>
+            ))}
+          </div>
+          <div className="lp-promise-row">
+            {promises.map(({ title, text }) => (
+              <div className="lp-promise" key={title}>
+                <CheckCircle2 size={16} />
+                <div>
+                  <strong>{title}</strong>
+                  <p>{text}</p>
+                </div>
+              </div>
+            ))}
+          </div>
+        </section>
+
+        <section className="lp-section lp-form-section" id="lp-form">
+          <div className="lp-form-layout">
+            <aside className="lp-form-aside">
+              <p className="lp-section-kicker">GET STARTED</p>
+              <h2>留下需求,专属顾问当天联系</h2>
+              <p className="lp-form-note">
+                只留联系方式也可以,细节进群后再聊。你留下的信息仅用于课程制作咨询。
+              </p>
+              <ul className="lp-form-points">
+                <li>
+                  <Sparkles size={15} /> 不用准备完整方案,讲个方向就行
+                </li>
+                <li>
+                  <Sparkles size={15} /> 可先看样片,满意再谈量产
+                </li>
+                <li>
+                  <Sparkles size={15} /> 企业批量课程可单独排期
+                </li>
+              </ul>
+            </aside>
+
+            <div className="lp-form-card">
+              {submitted ? (
+                <div className="lp-success">
+                  <span className="lp-success-icon">
+                    <CheckCircle2 size={26} />
+                  </span>
+                  <h3>已收到你的需求</h3>
+                  <p>
+                    顾问会通过手机或微信与你联系。
+                    {registered ? ' 账号已开通,可直接进入工作台。' : ''}
+                  </p>
+
+                  <div className="lp-success-contact">
+                    <div className="lp-qr">
+                      <img src={contactQrCode} alt="微信群二维码" />
+                      <span>
+                        <QrCode size={13} /> 微信扫一扫 · 加群
+                      </span>
+                    </div>
+                    <ol className="lp-success-steps">
+                      <li>扫码加入「星痕课程制作沟通群」</li>
+                      <li>群内发送手机号后四位,顾问快速核对你提交的需求</li>
+                      <li>确认方向与排期后,先看样片再批量生产</li>
+                    </ol>
+                  </div>
+
+                  <div className="lp-wechat-row">
+                    <div>
+                      <span>或直接添加微信</span>
+                      <strong>{WECHAT_ID}</strong>
+                    </div>
+                    <button type="button" className="lp-button lp-button--ghost" onClick={copyWechat}>
+                      {copied ? <Check size={15} /> : <Copy size={15} />}
+                      {copied ? '已复制' : '复制微信号'}
+                    </button>
+                  </div>
+
+                  <div className="lp-success-actions">
+                    <a className="lp-button" href={registered ? '/' : '/login?mode=register'}>
+                      {registered ? '进入工作台' : '顺便注册一个账号'} <ArrowRight size={17} />
+                    </a>
+                    <a className="lp-text-link" href="#lp-services">
+                      再看看服务内容
+                    </a>
+                  </div>
+                </div>
+              ) : (
+                <form onSubmit={submit} noValidate>
+                  <div className="lp-field-row">
+                    <label className="lp-field">
+                      <span>
+                        姓名 <b>*</b>
+                      </span>
+                      <input name="name" required maxLength={80} autoComplete="name" placeholder="怎么称呼您" />
+                    </label>
+                    <label className="lp-field">
+                      <span>
+                        手机号 <b>*</b>
+                      </span>
+                      <input
+                        name="phone"
+                        required
+                        inputMode="numeric"
+                        pattern="1[0-9]{10}"
+                        maxLength={11}
+                        autoComplete="tel"
+                        placeholder="11 位手机号"
+                      />
+                    </label>
+                    <label className="lp-field">
+                      <span>
+                        微信号 <b>*</b>
+                      </span>
+                      <input name="wechat" required maxLength={80} placeholder="微信号,方便顾问加您" />
+                    </label>
+                    <label className="lp-field">
+                      <span>需求类型</span>
+                      <select name="needType" defaultValue="DIGITAL_HUMAN">
+                        {NEED_TYPES.map((o) => (
+                          <option key={o.value} value={o.value}>
+                            {o.label}
+                          </option>
+                        ))}
+                      </select>
+                    </label>
+                  </div>
+
+                  <label className="lp-field">
+                    <span>课程体量(选填)</span>
+                    <select name="courseScale" defaultValue="">
+                      {SCALES.map((o) => (
+                        <option key={o.value} value={o.value}>
+                          {o.label}
+                        </option>
+                      ))}
+                    </select>
+                  </label>
+
+                  <label className="lp-field">
+                    <span>简单说说你想做什么课(选填)</span>
+                    <textarea
+                      name="message"
+                      rows={4}
+                      maxLength={1000}
+                      placeholder="例如:一套 12 讲的职场沟通课,有 PPT 和讲稿,希望用我们讲师的形象出镜。"
+                    />
+                  </label>
+
+                  <div className="lp-optional">
+                    <button
+                      type="button"
+                      className="lp-optional-toggle"
+                      onClick={() => setOpenAccount(!openAccount)}
+                      aria-expanded={openAccount}
+                    >
+                      {openAccount ? '−' : '+'} (选填)设置密码,同时开通账号
+                    </button>
+                    {openAccount && (
+                      <div className="lp-optional-body">
+                        <label className="lp-field">
+                          <span>登录密码</span>
+                          <input
+                            name="password"
+                            type="password"
+                            minLength={6}
+                            maxLength={72}
+                            autoComplete="new-password"
+                            placeholder="至少 6 位"
+                          />
+                        </label>
+                        <p className="lp-optional-hint">
+                          开通后可在工作台查看课程进度与成片归档;不填也能提交需求。
+                        </p>
+                      </div>
+                    )}
+                  </div>
+
+                  {error && <div className="lp-error">{error}</div>}
+
+                  <button className="lp-button lp-button--block" type="submit" disabled={busy}>
+                    {busy ? '正在提交…' : '提交需求,等顾问联系'} <ArrowRight size={17} />
+                  </button>
+                  <p className="lp-privacy">
+                    提交即表示同意我们仅将该联系方式用于课程制作咨询,不会用于其他用途。
+                  </p>
+                </form>
+              )}
+            </div>
+          </div>
+        </section>
+
+        <section className="lp-final">
+          <div className="lp-orbit lp-orbit-c" aria-hidden="true" />
+          <p className="lp-section-kicker">START YOUR COURSE</p>
+          <h2>下一门课,交给数字人来讲。</h2>
+          <span>留下需求,先看样片,再决定怎么做。</span>
+          <a className="lp-button" href="#lp-form">
+            留下需求 <ArrowRight size={17} />
+          </a>
+        </section>
+      </main>
+
+      <footer className="lp-footer">
+        <div className="lp-footer-inner">
+          <div className="lp-footer-company">
+            <BrandMark compact />
+            <span>杭州纯粹星辰人工智能科技有限公司</span>
+            <span className="lp-footer-dot">·</span>
+            <span>2026 版权所有</span>
+          </div>
+          <div className="lp-footer-links">
+            <a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">
+              浙ICP备2026057502号
+            </a>
+            <span className="lp-footer-sep">·</span>
+            <a href="/help">帮助中心</a>
+          </div>
+        </div>
+      </footer>
+
+      <a className="lp-mobile-cta" href="#lp-form">
+        <Layers3 size={16} /> 留下需求,顾问当天联系
+      </a>
+    </div>
+  )
+}

+ 297 - 0
src/landing/landing.css

@@ -0,0 +1,297 @@
+/* 广告落地页专属样式,只在 landing.html 入口加载,与主站 styles.css 完全隔离。
+   设计 token 与主站保持一致:品牌蓝 #55b0f7、操作蓝 #0875bd、扁平无阴影、圆角 6px、按钮高 44px。 */
+
+:root {
+  --lp-blue: #55b0f7;
+  --lp-blue-action: #0875bd;
+  --lp-blue-dark: #075f9b;
+  --lp-blue-ink: #075078;
+  --lp-ink: #17213a;
+  --lp-muted: #78839a;
+  --lp-soft: #7b8699;
+  --lp-line: #e4e8f0;
+  --lp-paper: #fff;
+  --lp-radius: 12px;
+  --lp-button-radius: 6px;
+  --lp-button-height: 44px;
+}
+
+* { box-sizing: border-box; }
+
+body {
+  margin: 0;
+  min-width: 320px;
+  font-family: Inter, 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', sans-serif;
+  color: var(--lp-ink);
+  background: radial-gradient(circle at 85% 6%, rgba(85, 176, 247, 0.06), transparent 24%), #f5f7fb;
+  -webkit-font-smoothing: antialiased;
+}
+
+button, input, textarea, select { font: inherit; }
+button { cursor: pointer; }
+a { color: inherit; text-decoration: none; }
+button, a { -webkit-tap-highlight-color: transparent; }
+
+.lp-page { min-height: 100vh; display: flex; flex-direction: column; overflow-x: hidden; }
+
+/* ---------- 品牌标识 ---------- */
+.lp-brand { display: inline-flex; align-items: center; gap: 11px; user-select: none; }
+.lp-brand-mark { width: 38px; height: 24px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
+.lp-brand-mark svg { width: 100%; height: 100%; overflow: visible; display: block; }
+.lp-brand-text { display: inline-flex; align-items: baseline; gap: 8px; }
+.lp-brand-text strong {
+  font-family: 'Songti SC', 'Noto Serif SC', 'Source Han Serif CN', 'STSong', 'PingFang SC', serif;
+  font-size: 21px; font-weight: 600; letter-spacing: 0.2em; color: #121826; line-height: 1;
+}
+.lp-brand-text small { font-family: Inter, sans-serif; font-size: 9px; font-weight: 500; letter-spacing: 0.28em; color: #8c97aa; text-transform: uppercase; }
+.lp-brand--compact .lp-brand-mark { width: 28px; height: 18px; }
+.lp-brand--compact strong { font-size: 17px; letter-spacing: 0.16em; }
+
+/* ---------- 按钮 ---------- */
+.lp-button {
+  border: 0;
+  border-radius: var(--lp-button-radius);
+  min-height: var(--lp-button-height);
+  padding: 0 19px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 9px;
+  font-weight: 500;
+  font-size: 14px;
+  line-height: 1;
+  background: var(--lp-blue-action);
+  color: #fff;
+  transition: background-color 0.16s ease;
+}
+.lp-button:hover { background: var(--lp-blue-dark); color: #fff; }
+.lp-button:focus-visible { outline: 2px solid var(--lp-blue); outline-offset: 2px; }
+.lp-button--small { min-height: 34px; padding: 0 14px; font-size: 13px; }
+.lp-button--block { width: 100%; }
+.lp-button--ghost {
+  background: #fff;
+  color: var(--lp-blue-action);
+  border: 1px solid #dce2ed;
+  min-height: 38px;
+}
+.lp-button--ghost:hover { background: #f4f8fb; color: var(--lp-blue-dark); }
+
+.lp-text-link { color: var(--lp-muted); font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
+.lp-text-link:hover { color: var(--lp-blue-action); }
+
+/* ---------- 顶栏 ---------- */
+.lp-nav {
+  height: 76px;
+  max-width: 1400px;
+  width: 100%;
+  margin: 0 auto;
+  padding: 0 clamp(20px, 5vw, 76px);
+  display: flex;
+  align-items: center;
+  gap: 30px;
+  border-bottom: 1px solid rgba(220, 225, 235, 0.75);
+}
+.lp-nav-link { font-size: 14px; color: #6f7b90; font-weight: 500; }
+.lp-nav-link:hover { color: var(--lp-blue-action); }
+.lp-nav-actions { margin-left: auto; display: flex; align-items: center; gap: 22px; }
+.lp-nav-login { font-size: 14px; color: #6f7b90; }
+.lp-nav-login:hover { color: var(--lp-blue-action); }
+
+/* ---------- 主视觉 ---------- */
+.lp-hero {
+  width: min(1400px, 100%);
+  margin: 0 auto;
+  padding: 86px clamp(20px, 5vw, 76px) 76px;
+  display: grid;
+  grid-template-columns: 1.05fr 0.95fr;
+  gap: 40px;
+  align-items: center;
+  position: relative;
+}
+.lp-kicker { display: flex; align-items: center; gap: 8px; margin: 0 0 22px; font-size: 11px; font-weight: 600; letter-spacing: 0.18em; color: var(--lp-blue-action); text-transform: uppercase; }
+.lp-kicker span { width: 26px; height: 1px; background: var(--lp-blue); display: inline-block; }
+.lp-hero h1 { margin: 0; font-size: clamp(32px, 4.1vw, 54px); line-height: 1.22; letter-spacing: -0.04em; font-weight: 300; color: #19233a; }
+.lp-hero h1 em { font-style: normal; color: var(--lp-blue); }
+.lp-lead { color: var(--lp-soft); font-size: 15px; line-height: 1.85; margin: 24px 0 30px; max-width: 560px; }
+.lp-hero-actions { display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
+.lp-trust { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 30px; color: #8b96aa; font-size: 12px; }
+.lp-trust span { display: inline-flex; align-items: center; gap: 7px; }
+.lp-trust svg { color: var(--lp-blue-action); }
+
+.lp-hero-visual { position: relative; min-height: 380px; display: grid; place-items: center; }
+.lp-orbit { position: absolute; border: 1px solid rgba(72, 91, 125, 0.18); border-color: rgba(72, 91, 125, 0.22) transparent transparent transparent; border-radius: 50%; transform: rotate(-12deg); }
+.lp-orbit-a { width: 460px; height: 260px; right: -2%; top: 14%; }
+.lp-orbit-b { width: 340px; height: 190px; right: 4%; top: 26%; border-color: rgba(85, 176, 247, 0.16) transparent transparent transparent; }
+.lp-star { position: absolute; width: 7px; height: 7px; border-radius: 50%; background: #c4a05b; right: 12%; top: 22%; box-shadow: -300px 40px 0 -2px var(--lp-blue); }
+
+.lp-preview {
+  position: relative;
+  z-index: 2;
+  width: 100%;
+  max-width: 400px;
+  background: rgba(255, 255, 255, 0.9);
+  border: 1px solid #e5e9f1;
+  border-radius: var(--lp-radius);
+  padding: 16px;
+  box-shadow: 0 18px 50px rgba(24, 48, 91, 0.07);
+}
+.lp-preview-screen { background: linear-gradient(145deg, #eaf7ff, #f7f9fd); border-radius: 8px; padding: 16px; }
+.lp-preview-badge { font-size: 10px; letter-spacing: 0.14em; color: var(--lp-blue-ink); background: #dff1ff; display: inline-block; padding: 4px 8px; border-radius: 4px; }
+.lp-preview-figure { display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 22px 0 18px; }
+.lp-figure-head { width: 40px; height: 40px; border-radius: 50%; background: #cde6fb; display: block; }
+.lp-figure-body { width: 84px; height: 46px; border-radius: 42px 42px 10px 10px; background: #b9dbf6; display: block; }
+.lp-preview-progress { height: 4px; border-radius: 4px; background: #dfe9f3; overflow: hidden; }
+.lp-preview-progress i { display: block; width: 62%; height: 100%; background: var(--lp-blue); }
+.lp-preview-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 10px; font-size: 11px; color: #6f7b90; }
+.lp-preview-meta strong { color: var(--lp-blue-action); font-weight: 500; }
+.lp-preview-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; }
+.lp-preview-list li { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #9aa4b5; }
+.lp-preview-list li span { font-size: 10px; color: #aab3c2; letter-spacing: 0.1em; }
+.lp-preview-list li.done { color: #5f6b80; }
+.lp-preview-list li.current { color: var(--lp-blue-action); font-weight: 500; }
+.lp-preview-list li.current span { color: var(--lp-blue); }
+
+/* ---------- 通用分区 ---------- */
+.lp-section { width: min(1400px, 100%); margin: 0 auto; padding: 74px clamp(20px, 5vw, 76px); }
+.lp-section--soft { background: rgba(255, 255, 255, 0.55); border-top: 1px solid var(--lp-line); border-bottom: 1px solid var(--lp-line); }
+.lp-section-head { max-width: 720px; margin-bottom: 36px; }
+.lp-section-kicker { margin: 0 0 8px; font-size: 9px; letter-spacing: 0.22em; font-weight: 600; color: #a0aabc; }
+.lp-section-head h2 { margin: 6px 0 0; font-size: clamp(24px, 2.6vw, 32px); font-weight: 400; letter-spacing: -0.02em; }
+.lp-section-lead { color: var(--lp-soft); font-size: 14px; line-height: 1.8; margin: 14px 0 0; }
+
+.lp-service-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
+.lp-service-card { background: var(--lp-paper); border: 1px solid #e5e9f1; border-radius: var(--lp-radius); padding: 24px 22px; transition: border-color 0.2s ease, box-shadow 0.2s ease; }
+.lp-service-card:hover { border-color: #cbd7ee; box-shadow: 0 12px 40px rgba(24, 48, 91, 0.07); }
+.lp-service-icon { width: 38px; height: 38px; border-radius: 8px; display: grid; place-items: center; background: linear-gradient(145deg, #eaf7ff, #f7f9fd); color: var(--lp-blue-action); }
+.lp-service-card h3 { margin: 16px 0 8px; font-size: 17px; font-weight: 500; }
+.lp-service-card p { margin: 0; font-size: 13px; line-height: 1.8; color: #8c96a8; }
+
+.lp-step-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
+.lp-step-card { background: rgba(255, 255, 255, 0.75); border: 1px solid var(--lp-line); border-radius: var(--lp-radius); padding: 22px; position: relative; }
+.lp-step-number { font-size: 11px; letter-spacing: 0.18em; color: #c2cada; }
+.lp-step-card h3 { margin: 12px 0 8px; font-size: 16px; font-weight: 500; }
+.lp-step-card p { margin: 0; font-size: 13px; line-height: 1.75; color: #8c96a8; }
+.lp-promise-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-top: 22px; }
+.lp-promise { display: flex; gap: 12px; align-items: flex-start; padding: 16px 18px; background: #fff; border: 1px solid var(--lp-line); border-radius: var(--lp-radius); }
+.lp-promise svg { color: var(--lp-blue-action); margin-top: 2px; flex-shrink: 0; }
+.lp-promise strong { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; }
+.lp-promise p { margin: 0; font-size: 12px; line-height: 1.7; color: #8c96a8; }
+
+/* ---------- 表单 ---------- */
+.lp-form-section { padding-bottom: 90px; }
+.lp-form-layout { display: grid; grid-template-columns: minmax(260px, 360px) minmax(480px, 660px); gap: clamp(32px, 6vw, 84px); align-items: start; justify-content: center; }
+.lp-form-aside h2 { margin: 10px 0 0; font-size: clamp(26px, 3vw, 34px); font-weight: 300; line-height: 1.45; letter-spacing: -0.03em; }
+.lp-form-note { color: var(--lp-muted); font-size: 13px; line-height: 1.8; margin: 18px 0 0; }
+.lp-form-points { list-style: none; margin: 26px 0 0; padding: 0; display: grid; gap: 12px; }
+.lp-form-points li { display: flex; align-items: center; gap: 9px; font-size: 13px; color: #5f6b80; }
+.lp-form-points svg { color: var(--lp-blue); flex-shrink: 0; }
+
+.lp-form-card { background: #fff; border: 1px solid #e5e9f0; border-radius: 13px; padding: 34px 36px; box-shadow: 0 20px 60px rgba(40, 57, 90, 0.06); }
+.lp-field-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
+.lp-field { display: flex; flex-direction: column; gap: 9px; font-size: 12px; color: #59657b; margin: 0 0 20px; }
+.lp-field span b { color: #d86f67; font-weight: 600; }
+.lp-field input, .lp-field select, .lp-field textarea {
+  border: 1px solid #e1e6ee;
+  border-radius: var(--lp-button-radius);
+  padding: 11px 13px;
+  font-size: 14px;
+  color: var(--lp-ink);
+  background: #fff;
+  outline: none;
+  transition: border-color 0.16s ease, box-shadow 0.16s ease;
+  width: 100%;
+}
+.lp-field textarea { resize: vertical; line-height: 1.7; }
+.lp-field input:focus, .lp-field select:focus, .lp-field textarea:focus {
+  border-color: var(--lp-blue);
+  box-shadow: 0 0 0 3px rgba(85, 176, 247, 0.16);
+}
+.lp-field input::placeholder, .lp-field textarea::placeholder { color: #b3bbc8; }
+
+.lp-optional { border-top: 1px dashed #e1e6ee; padding-top: 18px; margin-bottom: 22px; }
+.lp-optional-toggle { background: none; border: 0; padding: 0; font-size: 13px; color: var(--lp-blue-action); }
+.lp-optional-toggle:hover { color: var(--lp-blue-dark); }
+.lp-optional-body { margin-top: 16px; }
+.lp-optional-hint { margin: -8px 0 0; font-size: 12px; color: #939cad; }
+
+.lp-error { background: #fee2e2; color: #b91c1c; border-radius: var(--lp-button-radius); padding: 10px 13px; font-size: 13px; margin-bottom: 16px; }
+.lp-privacy { margin: 14px 0 0; font-size: 11px; line-height: 1.7; color: #a2abb9; }
+
+/* ---------- 提交成功态 ---------- */
+.lp-success { text-align: left; }
+.lp-success-icon { width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; background: #dcfce7; color: #15803d; }
+.lp-success h3 { margin: 16px 0 8px; font-size: 22px; font-weight: 500; }
+.lp-success > p { margin: 0; color: var(--lp-muted); font-size: 14px; line-height: 1.8; }
+.lp-success-contact { display: grid; grid-template-columns: 148px minmax(0, 1fr); gap: 22px; align-items: center; margin: 24px 0; padding: 20px; background: #f7f9fd; border: 1px solid var(--lp-line); border-radius: var(--lp-radius); }
+.lp-qr { display: grid; gap: 8px; justify-items: center; }
+.lp-qr img { width: 132px; height: 132px; object-fit: cover; border-radius: 8px; border: 1px solid var(--lp-line); background: #fff; }
+.lp-qr span { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: #6f7b90; }
+.lp-success-steps { margin: 0; padding-left: 20px; color: #5f6b80; font-size: 13px; line-height: 1.9; }
+.lp-wechat-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border: 1px solid var(--lp-line); border-radius: var(--lp-radius); }
+.lp-wechat-row span { display: block; font-size: 11px; color: #939cad; margin-bottom: 4px; }
+.lp-wechat-row strong { font-size: 16px; font-weight: 500; letter-spacing: 0.02em; }
+.lp-success-actions { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; margin-top: 24px; }
+
+/* ---------- 收尾 CTA 与页脚 ---------- */
+.lp-final { position: relative; overflow: hidden; text-align: center; padding: 76px clamp(20px, 5vw, 76px); background: #fff; border-top: 1px solid var(--lp-line); }
+.lp-orbit-c { width: 520px; height: 260px; left: 50%; transform: translateX(-50%) rotate(-12deg); top: -120px; border-color: rgba(85, 176, 247, 0.16) transparent transparent transparent; }
+.lp-final h2 { margin: 8px 0 10px; font-size: clamp(24px, 3vw, 34px); font-weight: 300; letter-spacing: -0.03em; position: relative; }
+.lp-final span { display: block; color: var(--lp-muted); font-size: 14px; margin-bottom: 26px; position: relative; }
+.lp-final .lp-button { position: relative; }
+
+.lp-footer { border-top: 1px solid var(--lp-line); background: rgba(255, 255, 255, 0.6); padding: 22px clamp(20px, 5vw, 76px) 96px; }
+.lp-footer-inner { max-width: 1400px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
+.lp-footer-company { display: flex; align-items: center; gap: 10px; font-size: 12px; color: #8c96a8; }
+.lp-footer-dot, .lp-footer-sep { color: #c3cad6; }
+.lp-footer-links { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #8c96a8; }
+.lp-footer-links a:hover { color: var(--lp-blue-action); }
+
+.lp-mobile-cta {
+  position: fixed;
+  left: 16px;
+  right: 16px;
+  bottom: 16px;
+  z-index: 30;
+  display: none;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  min-height: 46px;
+  border-radius: var(--lp-button-radius);
+  background: var(--lp-blue-action);
+  color: #fff;
+  font-size: 14px;
+  box-shadow: 0 8px 24px rgba(8, 117, 189, 0.28);
+}
+
+/* ---------- 响应式 ---------- */
+@media (max-width: 1080px) {
+  .lp-service-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .lp-step-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+  .lp-promise-row { grid-template-columns: 1fr; }
+  .lp-form-layout { grid-template-columns: 1fr; }
+}
+
+@media (max-width: 860px) {
+  .lp-nav { height: 62px; gap: 18px; }
+  .lp-nav-link { display: none; }
+  .lp-nav-actions { gap: 14px; }
+  .lp-nav-login { display: none; }
+  .lp-hero { grid-template-columns: 1fr; padding: 52px 20px 40px; }
+  .lp-hero-visual { min-height: 320px; order: -1; }
+  .lp-orbit-a { width: 320px; height: 190px; }
+  .lp-orbit-b { display: none; }
+  .lp-star { display: none; }
+  .lp-section { padding: 52px 20px; }
+  .lp-form-section { padding-bottom: 60px; }
+  .lp-form-card { padding: 26px 20px; }
+  .lp-field-row { grid-template-columns: 1fr; }
+  .lp-success-contact { grid-template-columns: 1fr; justify-items: center; text-align: left; }
+  .lp-mobile-cta { display: flex; }
+}
+
+@media (max-width: 560px) {
+  .lp-service-grid, .lp-step-grid { grid-template-columns: 1fr; }
+  .lp-wechat-row { flex-direction: column; align-items: flex-start; }
+  .lp-trust { gap: 12px; }
+}

+ 12 - 0
src/landing/main.tsx

@@ -0,0 +1,12 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { LandingPage } from './LandingPage'
+import './landing.css'
+
+// 广告落地页独立入口:不挂载主站的 AuthProvider / WorkspaceProvider / 路由,
+// 因此登录态、强制改密、主站路由重定向都不会影响本页面。
+ReactDOM.createRoot(document.getElementById('lp-root')!).render(
+  <React.StrictMode>
+    <LandingPage />
+  </React.StrictMode>,
+)

+ 39 - 6
src/pages/OrganizationManage.tsx

@@ -63,12 +63,14 @@ export function OrganizationManage() {
 
   async function createUser(e: FormEvent<HTMLFormElement>) {
     e.preventDefault()
-    const form = new FormData(e.currentTarget)
+    const formElement = e.currentTarget
+    const form = new FormData(formElement)
+    setError('')
     try {
       const result = await api.organizations.createUser(organization!.id, String(form.get('phone')), String(form.get('contactName')))
       setMessage(`已创建 ${result.user.contactName}:手机号 ${result.user.phone},初始密码 ${result.initialPassword}`)
       setCreatedLogin({ phone: result.user.phone, password: result.initialPassword })
-      ;(e.currentTarget as HTMLFormElement).reset()
+      formElement.reset()
       await load()
     } catch (x) { setError((x as Error).message) }
   }
@@ -104,6 +106,23 @@ export function OrganizationManage() {
     } catch (x) { setError((x as Error).message) } finally { setSavingMemberName(false) }
   }
 
+  async function removeMember(member: { id: string; contact_name?: string; phone?: string }) {
+    if (!confirm(`确定将「${member.contact_name || member.phone}」移出组织?移出后该成员将无法访问本组织课程。`)) return
+    setError('')
+    try {
+      await api.organizations.removeMember(organization!.id, member.id)
+      setMessage(`已将「${member.contact_name || member.phone}」移出组织`)
+      await load()
+    } catch (x) { setError((x as Error).message) }
+  }
+
+  useEffect(() => {
+    if (manage && window.location.hash) {
+      const el = document.querySelector(window.location.hash)
+      if (el) el.scrollIntoView({ behavior: 'smooth' })
+    }
+  }, [manage])
+
   function toggleCourseSort(field: CourseSortField) {
     if (courseSortField === field) {
       setCourseSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'))
@@ -121,14 +140,14 @@ export function OrganizationManage() {
       <div className="admin-header-row"><div><h1>{organization.name} · 机构设置</h1><p>成员、课程与组织账单</p></div></div>
     {error && <div className="notice notice-error">{error}</div>}
     {message && <div className="notice notice-success">{message}</div>}
-    {createdLogin && <div className="org-created-login"><span>请将登录信息交给新老师,并提醒首次登录修改密码。</span>
+    {createdLogin && <div className="org-created-login"><span>请将登录信息交给新老师。老师首次登录后可直接进入工作区。</span>
       <button className="secondary-button small-button" onClick={() => navigator.clipboard.writeText(`手机号:${createdLogin.phone}\n初始密码:${createdLogin.password}`)}>复制登录信息</button></div>}
     {!content || !manage ? <div className="center-loading">正在加载组织数据…</div> : <>
       {!organization.isActive && <div className="notice notice-error">该组织已停用:可查看和处理历史课程,暂不能新增成员或课程。</div>}
       <div className="stats-grid"><div className="stat-card"><div className="stat-content"><small>组织成员</small><strong>{manage.members.length}</strong></div></div>
         <div className="stat-card"><div className="stat-content"><small>组织课程</small><strong>{content.courses.length}</strong></div></div>
         <div className="stat-card"><div className="stat-content"><small>组织积分余额</small><strong>{content.organization.pointsBalance.toLocaleString('zh-CN')}</strong></div></div></div>
-      <section className="admin-section-block"><h2>现场创建老师</h2><form onSubmit={createUser} className="org-inline-form">
+      <section className="admin-section-block" id="create-teacher"><h2>现场创建老师</h2><form onSubmit={createUser} className="org-inline-form">
         <label>手机号<input name="phone" required pattern="1[0-9]{10}" inputMode="numeric" /></label>
         <label>姓名<input name="contactName" required maxLength={80} /></label>
         <button className="primary-button" disabled={!organization.isActive}>创建账号(初始密码 888888)</button>
@@ -235,8 +254,22 @@ export function OrganizationManage() {
           </table>
         </div>
       </section>
-      <section className="admin-section-block"><h2>人员名单</h2><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>姓名</th><th>手机号</th><th>组织身份</th><th>操作</th></tr></thead><tbody>
-        {manage.members.map((m: any) => <tr key={m.id}><td>{editingMemberId === m.id ? <input aria-label={`${m.phone} 的姓名`} value={editingMemberName} maxLength={80} autoFocus onChange={(e) => setEditingMemberName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveMemberName(m.id); if (e.key === 'Escape') setEditingMemberId(null) }} /> : (m.contact_name || '未填写')}</td><td>{m.phone}</td><td>{m.role === 'MANAGER' ? '组织管理员' : '成员'}</td><td>{editingMemberId === m.id ? <div className="action-button-group"><button className="primary-button small-button" disabled={savingMemberName} onClick={() => saveMemberName(m.id)}>{savingMemberName ? '保存中…' : '保存'}</button><button className="secondary-button small-button" disabled={savingMemberName} onClick={() => setEditingMemberId(null)}>取消</button></div> : <button className="secondary-button small-button" onClick={() => { setEditingMemberId(m.id); setEditingMemberName(m.contact_name || '') }}>修改姓名</button>}</td></tr>)}
+      <section className="admin-section-block" id="members">
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
+          <h2 style={{ margin: 0 }}>人员名单</h2>
+          <a
+            href="#create-teacher"
+            className="secondary-button small-button"
+            onClick={(e) => {
+              e.preventDefault()
+              document.getElementById('create-teacher')?.scrollIntoView({ behavior: 'smooth' })
+            }}
+          >
+            + 现场创建老师
+          </a>
+        </div>
+        <div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>姓名</th><th>手机号</th><th>组织身份</th><th>操作</th></tr></thead><tbody>
+        {manage.members.map((m: any) => <tr key={m.id}><td>{editingMemberId === m.id ? <input aria-label={`${m.phone} 的姓名`} value={editingMemberName} maxLength={80} autoFocus onChange={(e) => setEditingMemberName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveMemberName(m.id); if (e.key === 'Escape') setEditingMemberId(null) }} /> : (m.contact_name || '未填写')}</td><td>{m.phone}</td><td>{m.role === 'MANAGER' ? '组织管理员' : '成员'}</td><td>{editingMemberId === m.id ? <div className="action-button-group"><button className="primary-button small-button" disabled={savingMemberName} onClick={() => saveMemberName(m.id)}>{savingMemberName ? '保存中…' : '保存'}</button><button className="secondary-button small-button" disabled={savingMemberName} onClick={() => setEditingMemberId(null)}>取消</button></div> : <div className="action-button-group"><button className="secondary-button small-button" onClick={() => { setEditingMemberId(m.id); setEditingMemberName(m.contact_name || '') }}>修改姓名</button>{m.id !== user?.id && <button className="secondary-button small-button" style={{ color: '#ef4444' }} onClick={() => removeMember(m)}>移出组织</button>}</div>}</td></tr>)}
       </tbody></table></div></section>
       <section className="admin-section-block"><h2>组织积分流水</h2><div className="admin-table-wrapper"><table className="admin-table"><thead><tr><th>时间</th><th>事项</th><th>老师</th><th>积分变化</th></tr></thead><tbody>
         {manage.ledger.map((l: any) => <tr key={l.id}><td>{new Date(l.created_at).toLocaleString('zh-CN')}</td><td>{l.reason}</td><td>{l.beneficiary_name || '组织账户'}</td><td>{l.amount}</td></tr>)}

+ 13 - 7
src/pages/OrganizationPage.tsx

@@ -10,7 +10,7 @@ import type { CourseStatus } from '../types'
 export function OrganizationPage() {
   const { slug = '' } = useParams()
   const { user } = useAuth()
-  const { organizations } = useWorkspace()
+  const { organizations, selectWorkspace } = useWorkspace()
   const [content, setContent] = useState<any>(null)
   const [orgInfo, setOrgInfo] = useState<{ id: string; name: string; isActive: boolean } | null>(null)
   const [loading, setLoading] = useState(true)
@@ -147,12 +147,9 @@ export function OrganizationPage() {
           <p>组织课程与成员</p>
         </div>
         <div className="action-button-group">
-          <Link className="secondary-button" to="/">
-            返回工作台
-          </Link>
           {content.organization.role === 'MANAGER' && (
             <Link className="primary-button" to={`/organizations/${slug}/manage`}>
-              机构设置
+              机构管理
             </Link>
           )}
         </div>
@@ -216,7 +213,9 @@ export function OrganizationPage() {
               <tbody>
                 {sortedCourses.map((course: any) => (
                   <tr key={course.id}>
-                    <td><strong>{course.name}</strong></td>
+                    <td><strong>{content.organization.role === 'MANAGER' ? (
+                      <Link to={`/courses/${course.id}`} onClick={() => selectWorkspace(content.organization.id)}>{course.name}</Link>
+                    ) : course.name}</strong></td>
                     <td>{course.creator_name || '未填写'}</td>
                     <td>
                       <StatusBadge status={course.status as CourseStatus} />
@@ -232,7 +231,14 @@ export function OrganizationPage() {
       </div>
 
       <div className="admin-section-block">
-        <h2>人员名单</h2>
+        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
+          <h2 style={{ margin: 0 }}>人员名单</h2>
+          {content.organization.role === 'MANAGER' && (
+            <Link className="secondary-button small-button" to={`/organizations/${slug}/manage#members`}>
+              管理
+            </Link>
+          )}
+        </div>
         <div className="org-member-grid">
           {content.members.map((member: any) => (
             <span key={member.id}>

+ 2 - 3
src/pages/Profile.tsx

@@ -19,7 +19,7 @@ export function Profile() {
   const { organizations, currentOrganization } = useWorkspace()
   const [searchParams, setSearchParams] = useSearchParams()
   const requestedTab = searchParams.get('tab')
-  const activeTab: ProfileTab = user?.mustChangePassword ? 'security' : tabs.some((tab) => tab.id === requestedTab)
+  const activeTab: ProfileTab = tabs.some((tab) => tab.id === requestedTab)
     ? (requestedTab as ProfileTab)
     : 'profile'
   const [message, setMessage] = useState('')
@@ -95,7 +95,7 @@ export function Profile() {
             <div><strong>{user.contactName || '我的账户'}</strong><span>{user.phone}</span></div>
           </div>
           <nav aria-label="个人中心菜单">
-            {tabs.filter((tab) => !user.mustChangePassword || tab.id === 'security').map(({ id, label, description, icon: Icon }) => (
+            {tabs.map(({ id, label, description, icon: Icon }) => (
               <button key={id} className={activeTab === id ? 'active' : ''} onClick={() => changeTab(id)}>
                 <Icon size={18} />
                 <span><strong>{label}</strong><small>{description}</small></span>
@@ -169,7 +169,6 @@ export function Profile() {
           {activeTab === 'security' && (
             <form className="paper-form profile-panel profile-security" onSubmit={password}>
               <div className="form-title"><h2>账户安全</h2><p>定期更新密码有助于保护您的账户</p></div>
-              {user.mustChangePassword && <div className="notice notice-error">这是组织管理员创建的初始密码。请先设置自己的密码,再进入工作区。</div>}
               <label>当前密码<input name="currentPassword" type="password" required /></label>
               <label>新密码<input name="newPassword" type="password" minLength={6} required placeholder="至少 6 位" /></label>
               <label>确认新密码<input name="confirm" type="password" minLength={6} required /></label>

+ 5 - 0
src/pages/admin/AdminLayout.tsx

@@ -3,6 +3,7 @@ import {
   Clock3,
   LayoutDashboard,
   LogOut,
+  Megaphone,
   ShieldCheck,
   Users
 } from 'lucide-react'
@@ -58,6 +59,10 @@ export function AdminLayout() {
           </NavLink>
           <NavLink to="/admin/organizations" className={({ isActive }) => (isActive ? 'active' : '')}>组织管理</NavLink>
           <NavLink to="/admin/digital-instructors" className={({ isActive }) => (isActive ? 'active' : '')}>数字讲师</NavLink>
+          <NavLink to="/admin/leads" className={({ isActive }) => (isActive ? 'active' : '')}>
+            <Megaphone size={16} />
+            <span>落地页线索</span>
+          </NavLink>
         </nav>
 
         <div className="admin-top-actions">

+ 310 - 0
src/pages/admin/AdminLeads.tsx

@@ -0,0 +1,310 @@
+import { Megaphone, Phone, RotateCw, Search, X } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { api } from '../../api'
+import type { Lead, LeadStatus } from '../../types'
+import { formatDateTime } from '../../utils/date'
+
+const NEED_LABELS: Record<string, string> = {
+  DIGITAL_HUMAN: '数字人讲师出镜',
+  VOICE_CLONE: '声音复刻配音',
+  SCRIPT_TO_COURSE: '讲稿 / PPT 转分集',
+  ENTERPRISE_TRAINING: '企业内训批量课程',
+  OTHER: '其他定制需求',
+}
+
+const SCALE_LABELS: Record<string, string> = {
+  LT10: '10 讲以内',
+  '10_30': '10 - 30 讲',
+  '30_100': '30 - 100 讲',
+  GT100: '100 讲以上',
+}
+
+const STATUS_LABELS: Record<LeadStatus, string> = {
+  PENDING: '待联系',
+  CONTACTED: '已联系',
+  WON: '已成交',
+  INVALID: '无效',
+}
+
+const STATUS_CLASS: Record<LeadStatus, string> = {
+  PENDING: 'status--waiting_production',
+  CONTACTED: 'status--in_production',
+  WON: 'status--completed',
+  INVALID: 'status--draft',
+}
+
+export function AdminLeads() {
+  const [leads, setLeads] = useState<Lead[]>([])
+  const [loading, setLoading] = useState(true)
+  const [error, setError] = useState('')
+  const [successMsg, setSuccessMsg] = useState('')
+  const [keyword, setKeyword] = useState('')
+  const [status, setStatus] = useState<LeadStatus | ''>('')
+  const [busyId, setBusyId] = useState<string | null>(null)
+  const [editing, setEditing] = useState<Lead | null>(null)
+  const [editStatus, setEditStatus] = useState<LeadStatus>('PENDING')
+  const [editNote, setEditNote] = useState('')
+
+  const loadLeads = () => {
+    setLoading(true)
+    api.admin
+      .listLeads({ status, keyword })
+      .then((r) => setLeads(r.leads))
+      .catch((err) => setError(err.message))
+      .finally(() => setLoading(false))
+  }
+
+  useEffect(() => {
+    loadLeads()
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [status])
+
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault()
+    loadLeads()
+  }
+
+  const quickStatus = async (lead: Lead, next: LeadStatus) => {
+    setBusyId(lead.id)
+    setError('')
+    setSuccessMsg('')
+    try {
+      const r = await api.admin.updateLead(lead.id, { status: next })
+      setLeads((prev) => prev.map((l) => (l.id === lead.id ? r.lead : l)))
+      setSuccessMsg(`${lead.name || lead.phone} 已标记为「${STATUS_LABELS[next]}」`)
+    } catch (err: any) {
+      setError(err.message || '更新失败')
+    } finally {
+      setBusyId(null)
+    }
+  }
+
+  const openEdit = (lead: Lead) => {
+    setEditing(lead)
+    setEditStatus(lead.status)
+    setEditNote(lead.note || '')
+  }
+
+  const saveEdit = async () => {
+    if (!editing) return
+    setBusyId(editing.id)
+    setError('')
+    try {
+      const r = await api.admin.updateLead(editing.id, { status: editStatus, note: editNote })
+      setLeads((prev) => prev.map((l) => (l.id === editing.id ? r.lead : l)))
+      setSuccessMsg(`线索 ${editing.phone} 已更新`)
+      setEditing(null)
+    } catch (err: any) {
+      setError(err.message || '保存失败')
+    } finally {
+      setBusyId(null)
+    }
+  }
+
+  const pendingCount = leads.filter((l) => l.status === 'PENDING').length
+
+  return (
+    <div className="page admin-page">
+      <div className="admin-header-row">
+        <div>
+          <h2>落地页线索</h2>
+          <p className="admin-subtitle">广告落地页 /lp 提交的课程咨询,留下联系方式后由顾问跟进</p>
+        </div>
+        <div className="action-button-group">
+          <button className="secondary-button" onClick={loadLeads} title="刷新列表">
+            <RotateCw size={15} /> 刷新
+          </button>
+        </div>
+      </div>
+
+      {error && <div className="notice notice-error">{error}</div>}
+      {successMsg && <div className="notice notice-success">{successMsg}</div>}
+
+      <div className="admin-filter-bar">
+        <form className="search-form" onSubmit={handleSearch} style={{ maxWidth: 420 }}>
+          <div className="input-with-icon">
+            <Search size={16} />
+            <input
+              value={keyword}
+              onChange={(e) => setKeyword(e.target.value)}
+              placeholder="搜索姓名、手机号、微信或需求…"
+            />
+          </div>
+          <button type="submit" className="secondary-button">
+            搜索
+          </button>
+        </form>
+
+        <select
+          value={status}
+          onChange={(e) => setStatus(e.target.value as LeadStatus | '')}
+          className="status-filter-select"
+          aria-label="按状态筛选"
+        >
+          <option value="">全部状态</option>
+          {(Object.keys(STATUS_LABELS) as LeadStatus[]).map((s) => (
+            <option key={s} value={s}>
+              {STATUS_LABELS[s]}
+            </option>
+          ))}
+        </select>
+
+        <div className="user-stat-summary">
+          <span>共 {leads.length} 条线索</span>
+          {pendingCount > 0 && <span>· {pendingCount} 条待联系</span>}
+        </div>
+      </div>
+
+      {loading ? (
+        <div className="center-loading">正在读取线索列表…</div>
+      ) : leads.length === 0 ? (
+        <div className="empty-state">
+          <Megaphone size={32} style={{ color: '#9aa3b3', marginBottom: 8 }} />
+          <h3>暂时没有线索</h3>
+          <p>把落地页地址 /lp 投放到广告位后,提交的联系方式会出现在这里。</p>
+        </div>
+      ) : (
+        <div className="admin-table-wrapper">
+          <table className="admin-table">
+            <thead>
+              <tr>
+                <th>联系人</th>
+                <th>需求类型 / 体量</th>
+                <th>需求描述</th>
+                <th>来源</th>
+                <th>提交时间</th>
+                <th>状态</th>
+                <th style={{ textAlign: 'right' }}>操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              {leads.map((l) => (
+                <tr key={l.id}>
+                  <td>
+                    <div className="user-cell">
+                      <strong>{l.name || '未填写姓名'}</strong>
+                      <small className="sub-text">
+                        <Phone size={11} /> {l.phone}
+                      </small>
+                      <small className="sub-text">微信:{l.wechat || '未填写'}</small>
+                      {l.userName && <small className="user-bio-tip">已注册:{l.userName}</small>}
+                    </div>
+                  </td>
+                  <td>
+                    <span>{NEED_LABELS[l.needType] || '其他定制需求'}</span>
+                    {l.courseScale && <small className="sub-text">{SCALE_LABELS[l.courseScale] || l.courseScale}</small>}
+                  </td>
+                  <td>
+                    <div className="lead-message">{l.message || '(未填写描述)'}</div>
+                  </td>
+                  <td>
+                    <small>{l.source || '直接访问'}</small>
+                  </td>
+                  <td>
+                    <small>{formatDateTime(l.createdAt)}</small>
+                  </td>
+                  <td>
+                    <span className={`status ${STATUS_CLASS[l.status]}`}>
+                      <i />
+                      {STATUS_LABELS[l.status] || l.status}
+                    </span>
+                    {l.note && <small className="sub-text">备注:{l.note}</small>}
+                  </td>
+                  <td style={{ textAlign: 'right' }}>
+                    <div className="action-button-group">
+                      {l.status !== 'CONTACTED' && (
+                        <button
+                          type="button"
+                          className="secondary-button small-button"
+                          disabled={busyId === l.id}
+                          onClick={() => quickStatus(l, 'CONTACTED')}
+                        >
+                          已联系
+                        </button>
+                      )}
+                      {l.status !== 'WON' && (
+                        <button
+                          type="button"
+                          className="secondary-button small-button"
+                          disabled={busyId === l.id}
+                          onClick={() => quickStatus(l, 'WON')}
+                        >
+                          已成交
+                        </button>
+                      )}
+                      {l.status !== 'INVALID' && (
+                        <button
+                          type="button"
+                          className="secondary-button small-button"
+                          disabled={busyId === l.id}
+                          onClick={() => quickStatus(l, 'INVALID')}
+                        >
+                          无效
+                        </button>
+                      )}
+                      <button type="button" className="primary-button small-button" onClick={() => openEdit(l)}>
+                        备注
+                      </button>
+                    </div>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+
+      {editing && (
+        <div
+          className="modal-backdrop"
+          role="presentation"
+          onMouseDown={(e) => e.target === e.currentTarget && setEditing(null)}
+        >
+          <div className="modal-dialog" role="dialog" aria-modal="true" aria-labelledby="lead-edit-title">
+            <div className="modal-header">
+              <div>
+                <h3 id="lead-edit-title">线索跟进</h3>
+              </div>
+              <button className="close-modal-btn" onClick={() => setEditing(null)} aria-label="关闭">
+                <X size={18} />
+              </button>
+            </div>
+            <div className="create-user-body">
+              <p className="create-user-hint">
+                {editing.name || '未填写姓名'} · {editing.phone} · 微信 {editing.wechat || '未填写'}
+              </p>
+              <label className="create-user-field">
+                <span>跟进状态</span>
+                <select value={editStatus} onChange={(e) => setEditStatus(e.target.value as LeadStatus)}>
+                  {(Object.keys(STATUS_LABELS) as LeadStatus[]).map((s) => (
+                    <option key={s} value={s}>
+                      {STATUS_LABELS[s]}
+                    </option>
+                  ))}
+                </select>
+              </label>
+              <label className="create-user-field">
+                <span>跟进备注</span>
+                <textarea
+                  rows={5}
+                  maxLength={1000}
+                  value={editNote}
+                  onChange={(e) => setEditNote(e.target.value)}
+                  placeholder="例如:已电话沟通,客户需要 12 讲的职场沟通课,先看样片。"
+                />
+              </label>
+            </div>
+            <div className="modal-footer">
+              <button type="button" className="secondary-button" onClick={() => setEditing(null)}>
+                取消
+              </button>
+              <button type="button" className="primary-button" onClick={saveEdit} disabled={busyId === editing.id}>
+                {busyId === editing.id ? '保存中…' : '保存'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 3 - 0
src/styles.css

@@ -582,6 +582,9 @@ button { cursor:pointer; }
 .search-form input { border:1px solid #cbd5e1; border-radius:8px; padding:9px 12px 9px 36px; font-size:13px; background:#fff; outline:none; width:100%; box-sizing:border-box; }
 .search-form input:focus { border-color:var(--blue); box-shadow:0 0 0 3px rgba(85,176,247,.1); }
 .user-stat-summary { color:#64748b; font-size:13px; }
+.status-filter-select { border:1px solid #cbd5e1; border-radius:8px; padding:9px 12px; font-size:13px; background:#fff; color:#334155; outline:none; }
+.status-filter-select:focus { border-color:var(--blue); box-shadow:0 0 0 3px rgba(85,176,247,.1); }
+.lead-message { max-width:340px; font-size:12px; line-height:1.7; color:#64748b; white-space:pre-wrap; word-break:break-word; }
 
 /* 管理端表格 */
 .admin-table-wrapper { width:100%; overflow-x:auto; background:#fff; border:1px solid #e2e8f0; border-radius:14px; box-shadow:0 2px 8px rgba(0,0,0,.02); }

+ 19 - 0
src/types.ts

@@ -194,3 +194,22 @@ export interface CourseTemplate {
   assets: PresetAsset[]
   episodes?: PresetEpisode[]
 }
+
+export type LeadStatus = 'PENDING' | 'CONTACTED' | 'WON' | 'INVALID'
+
+export interface Lead {
+  id: string
+  name: string
+  phone: string
+  wechat: string
+  needType: string
+  courseScale: string
+  message: string
+  source: string
+  status: LeadStatus
+  note: string
+  userId: string | null
+  userName: string
+  createdAt: string
+  updatedAt: string
+}

+ 12 - 0
vite.config.ts

@@ -1,7 +1,19 @@
+import { fileURLToPath } from 'node:url'
 import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
 
+const entry = (p: string) => fileURLToPath(new URL(p, import.meta.url))
+
 export default defineConfig({
   plugins: [react()],
   server: { port: 5173, proxy: { '/api': 'http://localhost:3001', '/uploads': 'http://localhost:3001', '/sample': 'http://localhost:3001' } },
+  build: {
+    rollupOptions: {
+      // 主站与广告落地页各自独立入口,落地页不共享主站路由与全局样式。
+      input: {
+        main: entry('./index.html'),
+        landing: entry('./landing.html'),
+      },
+    },
+  },
 })