This commit is contained in:
2026-08-05 15:56:08 +08:00
parent 01729b1e0b
commit 2d9e2376b6
106 changed files with 10007 additions and 253 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.DS_Store
.env
**/.env
**/node_modules
**/dist
backend/vendor
backend/runtime/*
backend/uploads/*
backend/storage/*
logs
backend-tp
backend-tp8
+13
View File
@@ -0,0 +1,13 @@
# 复制为 .env 后再按需修改;不要提交包含真实密钥的 .env。
APP_PORT=8081
APP_PUBLIC_URL=http://localhost:8081
APP_DEBUG=false
MYSQL_ROOT_PASSWORD=请替换为强密码
DB_USER=ai_chat
DB_PASSWORD=请替换为独立的数据库用户密码
JWT_SECRET=请替换为至少32位随机字符串
JWT_EXPIRE=604800
# 可选:宿主机或局域网中的 CosyVoice 服务
COSYVOICE_ENABLED=false
COSYVOICE_BASE_URL=http://host.docker.internal:50000
+2
View File
@@ -1,2 +1,4 @@
logs/stability/
logs/conversation/
.DS_Store
.env
+63
View File
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS frontend-builder
WORKDIR /workspace
COPY frontend/package.json frontend/package-lock.json ./frontend/
COPY frontend-admin/package.json frontend-admin/package-lock.json ./frontend-admin/
RUN --mount=type=cache,target=/root/.npm \
npm --prefix frontend ci && npm --prefix frontend-admin ci
COPY frontend ./frontend
COPY frontend-admin ./frontend-admin
COPY scripts ./scripts
COPY backend/public ./backend/public
RUN npm --prefix frontend run build && npm --prefix frontend-admin run build
FROM php:8.2-fpm-bookworm AS app
WORKDIR /var/www/backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
ffmpeg \
fonts-noto-cjk \
libfreetype6-dev \
libjpeg62-turbo-dev \
libonig-dev \
libpng-dev \
libwebp-dev \
libzip-dev \
poppler-utils \
unzip \
&& docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install -j"$(nproc)" gd mbstring pdo_mysql zip \
&& mkdir -p /usr/local/share/cosyvoice \
&& curl -fsSL --retry 3 \
https://raw.githubusercontent.com/FunAudioLLM/CosyVoice/main/asset/zero_shot_prompt.wav \
-o /usr/local/share/cosyvoice/zero_shot_prompt.wav \
&& echo "c7b31d6dbe7cc6a716dded00550db5b50940bf209e424e4ad207b12e657c8ff6 /usr/local/share/cosyvoice/zero_shot_prompt.wav" | sha256sum -c - \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
COPY backend/composer.json backend/composer.lock ./
RUN --mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache composer install \
--no-dev --no-interaction --no-progress --prefer-dist \
--no-scripts --optimize-autoloader
COPY backend ./
COPY --from=frontend-builder /workspace/backend/public ./public
RUN composer run-script post-autoload-dump --no-interaction \
&& mkdir -p runtime uploads storage/cosyvoice \
&& chown -R www-data:www-data runtime uploads storage
COPY deploy/docker/php.ini /usr/local/etc/php/conf.d/99-app.ini
COPY deploy/docker/php-fpm.conf /usr/local/etc/php-fpm.d/zz-app.conf
COPY deploy/docker/app-entrypoint.sh /usr/local/bin/app-entrypoint
RUN chmod +x /usr/local/bin/app-entrypoint
ENTRYPOINT ["app-entrypoint"]
FROM nginx:1.28-alpine AS web
COPY deploy/docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=frontend-builder /workspace/backend/public /var/www/backend/public
+31
View File
@@ -130,6 +130,37 @@ npm run dev
## 生产部署
### Docker Compose 一键部署
项目提供 Nginx + PHP-FPM 8.2 + MySQL 8 的容器化部署。首次启动时会自动编译两个 Vue 前端、安装 Composer 生产依赖,并导入数据库结构。
```bash
cp .env.docker.example .env
# 修改 .env 中的 MYSQL_ROOT_PASSWORD 和 JWT_SECRET
docker compose up -d --build
```
默认访问地址:
- 会员端:`http://localhost:8081/`
- 管理后台:`http://localhost:8081/admin/`
- API`http://localhost:8081/api/`
查看状态和日志:
```bash
docker compose ps
docker compose logs -f web app db
```
停止服务(保留数据库与上传文件):
```bash
docker compose down
```
只有明确需要清空全部数据时才执行 `docker compose down -v`。如需改用其他端口,修改 `.env` 中的 `APP_PORT`
### 1. 一键编译并部署静态资源
在项目根目录执行:
+1 -2
View File
@@ -1,7 +1,6 @@
*.log
.env
composer.phar
composer.lock
.DS_Store
Thumbs.db
/.idea
@@ -9,4 +8,4 @@ Thumbs.db
/vendor
/.settings
/.buildpath
/.project
/.project
+185
View File
@@ -5,6 +5,7 @@ namespace app\controller\api;
use app\model\AiModel;
use app\model\Conversation as ConversationModel;
use app\model\Department;
use app\model\InvitationCode;
use app\model\MembershipLevel;
use app\model\Message;
use app\model\Role;
@@ -155,6 +156,7 @@ class Admin extends BaseApi
->leftJoin('roles r', 'u.role_id = r.id')
->leftJoin('departments d', 'u.department_id = d.id')
->field('u.id,u.username,u.email,u.nickname,u.role,u.role_id,u.department_id,u.status,u.membership_level_id,u.created_at,u.last_login_at,ml.name as membership_name,r.name as role_name,r.slug as role_slug,d.name as department_name')
->whereRaw('LEFT(u.username, 6) <> ?', ['guest_'])
->order('u.id', 'desc');
AdminScopeService::applyUserScope($query, $this->authUser(), 'u');
@@ -183,6 +185,9 @@ class Admin extends BaseApi
if (strlen($username) < 3 || strlen($username) > 50) {
return $this->error('用户名长度需 3-50 个字符');
}
if (str_starts_with(strtolower($username), 'guest_')) {
return $this->error('guest_ 为系统访客账号保留前缀');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('邮箱格式不正确');
}
@@ -249,6 +254,9 @@ class Admin extends BaseApi
if (!$user) {
return $this->error('用户不存在', 404);
}
if (str_starts_with($user->username, 'guest_')) {
return $this->error('访客请在访客管理中操作', 422);
}
$roleSlug = Role::where('id', $user->role_id)->value('slug');
if ($roleSlug === 'super_admin') {
@@ -322,6 +330,9 @@ class Admin extends BaseApi
if (!$user) {
return $this->error('用户不存在', 404);
}
if (str_starts_with($user->username, 'guest_')) {
return $this->error('访客请在访客管理中操作', 422);
}
User::where('id', $targetId)->update($data);
@@ -332,6 +343,77 @@ class Admin extends BaseApi
return $this->success(null, '更新成功');
}
public function guests()
{
$auth = $this->authUser();
AdminScopeService::requireAny($auth, ['menu:guests']);
$page = max(1, (int) $this->request->get('page', 1));
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
$status = trim((string) $this->request->get('status', ''));
$keyword = trim((string) $this->request->get('keyword', ''));
$query = User::alias('u')
->whereRaw('LEFT(u.username, 6) = ?', ['guest_'])
->field("u.id,u.username,u.nickname,u.status,u.created_at,u.last_login_at,
(SELECT COUNT(*) FROM conversations c WHERE c.user_id = u.id AND c.deleted_at IS NULL) AS conversation_count,
(SELECT COUNT(*) FROM messages msg INNER JOIN conversations c2 ON msg.conversation_id = c2.id WHERE c2.user_id = u.id) AS message_count")
->order('u.last_login_at', 'desc')
->order('u.id', 'desc');
AdminScopeService::applyUserScope($query, $auth, 'u');
if (in_array($status, ['active', 'disabled'], true)) {
$query->where('u.status', $status);
}
if ($keyword !== '') {
$query->where('u.username', 'like', '%' . $keyword . '%');
}
$total = (clone $query)->count();
$list = $query->page($page, $limit)->select();
return $this->success(['list' => $list, 'total' => $total]);
}
public function updateGuestStatus($id)
{
$auth = $this->authUser();
AdminScopeService::requireAny($auth, ['btn:guest:status']);
$guest = User::find((int) $id);
if (!$guest || !str_starts_with($guest->username, 'guest_')) {
return $this->error('访客不存在', 404);
}
if (!AdminScopeService::canViewUser($auth, (int) $guest->id)) {
return $this->error('无权操作该访客', 403);
}
$status = trim((string) $this->request->put('status', ''));
if (!in_array($status, ['active', 'disabled'], true)) {
return $this->error('访客状态无效', 422);
}
$guest->save(['status' => $status]);
return $this->success(null, $status === 'active' ? '访客已启用' : '访客已禁用');
}
public function deleteGuest($id)
{
$auth = $this->authUser();
AdminScopeService::requireAny($auth, ['btn:guest:delete']);
$guest = User::find((int) $id);
if (!$guest || !str_starts_with($guest->username, 'guest_')) {
return $this->error('访客不存在', 404);
}
if (!AdminScopeService::canViewUser($auth, (int) $guest->id)) {
return $this->error('无权操作该访客', 403);
}
User::destroy((int) $guest->id);
return $this->success(null, '访客记录已删除');
}
public function conversations()
{
AdminScopeService::requireAny($this->authUser(), [
@@ -557,6 +639,109 @@ class Admin extends BaseApi
return $this->success($options);
}
public function invitations()
{
AdminScopeService::requireAny($this->authUser(), [
'menu:invitations',
'btn:invitation:create',
'btn:invitation:revoke',
'can_manage_users',
]);
$status = trim((string) $this->request->get('status', ''));
$query = InvitationCode::alias('i')
->leftJoin('departments d', 'i.department_id = d.id')
->leftJoin('users creator', 'i.created_by = creator.id')
->leftJoin('users used', 'i.used_by = used.id')
->field('i.*,d.name as department_name,creator.username as creator_name,used.username as used_by_name')
->order('i.created_at', 'desc')
->order('i.id', 'desc');
if (in_array($status, ['active', 'used', 'revoked'], true)) {
$query->where('i.status', $status);
}
return $this->success($query->limit(500)->select());
}
public function createInvitation()
{
$user = $this->authUser();
AdminScopeService::requireAny($user, [
'btn:invitation:create',
'menu:invitations',
'can_manage_users',
]);
$input = $this->request->post();
$departmentId = $input['department_id'] ?? null;
if ($departmentId !== null && $departmentId !== '') {
$departmentId = (int) $departmentId;
if ($departmentId <= 0 || !Department::find($departmentId)) {
return $this->error('所选部门不存在', 422);
}
} else {
$departmentId = null;
}
$expiresAt = trim((string) ($input['expires_at'] ?? ''));
if ($expiresAt !== '') {
$expiresAt = str_replace('T', ' ', $expiresAt);
$expiresTimestamp = strtotime($expiresAt);
if (!$expiresTimestamp || $expiresTimestamp <= time()) {
return $this->error('过期时间必须晚于当前时间', 422);
}
$expiresAt = date('Y-m-d H:i:s', $expiresTimestamp);
} else {
$expiresAt = null;
}
do {
$code = 'INV-' . strtoupper(bin2hex(random_bytes(5)));
} while (InvitationCode::where('code', $code)->find());
$invitation = InvitationCode::create([
'code' => $code,
'department_id' => $departmentId,
'created_by' => (int) $user['id'],
'status' => 'active',
'expires_at' => $expiresAt,
]);
return $this->success([
'id' => (int) $invitation->id,
'code' => $invitation->code,
'department_id' => $departmentId,
'department_name' => $departmentId ? Department::where('id', $departmentId)->value('name') : null,
'status' => 'active',
'expires_at' => $expiresAt,
'created_at' => $invitation->created_at,
], '邀请码已生成');
}
public function revokeInvitation($id)
{
AdminScopeService::requireAny($this->authUser(), [
'btn:invitation:revoke',
'menu:invitations',
'can_manage_users',
]);
$invitation = InvitationCode::find((int) $id);
if (!$invitation) {
return $this->error('邀请码不存在', 404);
}
if ($invitation->status === 'used') {
return $this->error('已使用的邀请码不能作废', 422);
}
if ($invitation->status === 'revoked') {
return $this->success(null, '邀请码已作废');
}
$invitation->save(['status' => 'revoked']);
return $this->success(null, '邀请码已作废');
}
public function createDepartment()
{
AdminScopeService::requireAny($this->authUser(), ['btn:dept:create', 'can_manage_departments']);
+62 -9
View File
@@ -3,11 +3,14 @@
namespace app\controller\api;
use app\model\User;
use app\model\InvitationCode;
use app\model\MembershipLevel;
use app\model\Role;
use app\service\JwtService;
use app\service\SettingsService;
use app\service\UserContextService;
use think\exception\HttpResponseException;
use think\facade\Db;
class Auth extends BaseApi
{
@@ -51,6 +54,8 @@ class Auth extends BaseApi
return $this->error('游客访问暂不可用', 403);
}
$user->save(['last_login_at' => date('Y-m-d H:i:s')]);
$token = JwtService::generateToken(['user_id' => $user->id]);
return $this->success([
@@ -70,6 +75,7 @@ class Auth extends BaseApi
$username = trim($input['username'] ?? '');
$email = trim($input['email'] ?? '');
$password = $input['password'] ?? '';
$invitationCode = strtoupper(preg_replace('/\s+/', '', trim((string) ($input['invitation_code'] ?? ''))));
if (strlen($username) < 3 || strlen($username) > 50) {
return $this->error('用户名长度需 3-50 个字符');
@@ -80,21 +86,59 @@ class Auth extends BaseApi
if (strlen($password) < 6) {
return $this->error('密码至少 6 位');
}
if ($invitationCode === '') {
return $this->error('请输入邀请码', 422);
}
if (User::where('username', $username)->whereOr('email', $email)->find()) {
return $this->error('用户名或邮箱已存在');
}
$defaultRoleId = \app\model\Role::where('slug', 'user')->value('id');
$defaultRoleId = Role::where('slug', 'user')->value('id');
$membershipId = MembershipLevel::where('slug', 'free')->value('id') ?: 1;
$user = User::create([
'username' => $username,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
'nickname' => $username,
'membership_level_id' => 1,
'role_id' => $defaultRoleId ?: null,
]);
$user = Db::transaction(function () use (
$invitationCode,
$username,
$email,
$password,
$defaultRoleId,
$membershipId
) {
$invitation = InvitationCode::where('code', $invitationCode)->lock(true)->find();
if (!$invitation) {
$this->abortRegistration('邀请码不存在', 422);
}
if ($invitation->status !== 'active') {
$this->abortRegistration($invitation->status === 'used' ? '邀请码已被使用' : '邀请码已作废', 422);
}
if ($invitation->expires_at && strtotime((string) $invitation->expires_at) <= time()) {
$this->abortRegistration('邀请码已过期', 422);
}
if (User::where('username', $username)->whereOr('email', $email)->find()) {
$this->abortRegistration('用户名或邮箱已存在', 422);
}
$user = User::create([
'username' => $username,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
'nickname' => $username,
'role' => 'user',
'role_id' => $defaultRoleId ?: null,
'department_id' => $invitation->department_id ?: null,
'membership_level_id' => $membershipId,
'status' => 'active',
]);
$invitation->save([
'status' => 'used',
'used_by' => $user->id,
'used_at' => date('Y-m-d H:i:s'),
]);
return $user;
});
$token = JwtService::generateToken(['user_id' => $user->id]);
@@ -157,4 +201,13 @@ class Auth extends BaseApi
{
return UserContextService::formatPublicUser($userId);
}
private function abortRegistration(string $message, int $httpCode): never
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], $httpCode));
}
}
+19 -8
View File
@@ -12,6 +12,7 @@ use app\service\ComfyUIService;
use app\service\CosyVoiceService;
use app\service\DifyService;
use app\service\DocumentTextService;
use app\service\GuestAccessService;
use app\service\OpenAIService;
use app\service\PermissionService;
use app\service\SettingsService;
@@ -21,7 +22,8 @@ class Chat extends BaseApi
{
public function speech()
{
$this->authUser();
$user = $this->authUser();
GuestAccessService::assertAccountRequired($user, '语音对话');
$input = $this->request->post();
$text = trim((string) ($input['text'] ?? ''));
@@ -71,7 +73,8 @@ class Chat extends BaseApi
public function speechStream(): never
{
$this->authUser();
$user = $this->authUser();
GuestAccessService::assertAccountRequired($user, '语音对话');
$input = $this->request->post();
$text = trim((string) ($input['text'] ?? ''));
$requestId = trim((string) ($input['request_id'] ?? ''));
@@ -134,7 +137,8 @@ class Chat extends BaseApi
public function speechCancel()
{
$this->authUser();
$user = $this->authUser();
GuestAccessService::assertAccountRequired($user, '语音对话');
$requestId = trim((string) $this->request->post('request_id', ''));
if (!preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $requestId)) {
return $this->error('语音请求标识无效', 422);
@@ -168,6 +172,7 @@ class Chat extends BaseApi
if (!is_array($attachments)) {
return $this->error('附件格式无效', 422);
}
GuestAccessService::assertTextChatOnly($user, $attachments, $agentId, $imageTool, $voiceMode);
if (!$conversationId) {
return $this->error('缺少 conversation_id');
@@ -178,10 +183,6 @@ class Chat extends BaseApi
if ($imageTool !== '' && !$this->hasImageAttachments($attachments)) {
return $this->error('图片处理工具需要一张原图', 422);
}
if (!empty($user['is_guest']) && $this->hasImageAttachments($attachments)) {
return $this->error('游客模式不支持发送图片,请登录后重试', 403);
}
$agent = AgentCatalog::find($agentId);
if ($agentId !== '' && !$agent) {
return $this->error('所选 Agent 不存在或已停用', 422);
@@ -197,7 +198,17 @@ class Chat extends BaseApi
}
$imageGenerationContent = $content;
$model = OpenAIService::getModel($conversation->model_id ? (int) $conversation->model_id : null);
if (GuestAccessService::isGuest($user)) {
$model = GuestAccessService::model();
if ((int) $conversation->model_id !== (int) $model->id) {
$conversation->save([
'model_id' => (int) $model->id,
'external_conversation_id' => null,
]);
}
} else {
$model = OpenAIService::getModel($conversation->model_id ? (int) $conversation->model_id : null);
}
if ($imageTool !== '' && $imageTool !== 'commit') {
$preferredImageModelId = ($model->provider ?? '') === 'comfy' ? (int) ($model->id ?? 0) : null;
$model = OpenAIService::getImageModel($preferredImageModelId ?: null);
@@ -6,6 +6,7 @@ use app\model\AiModel;
use app\model\Conversation as ConversationModel;
use app\model\Message;
use app\service\ComfyUIService;
use app\service\GuestAccessService;
use app\service\PermissionService;
use think\exception\HttpResponseException;
@@ -14,6 +15,12 @@ class Conversation extends BaseApi
public function index()
{
$user = $this->authUser();
if (GuestAccessService::isGuest($user)) {
$guestModelId = GuestAccessService::modelId();
$reset = ['model_id' => $guestModelId, 'external_conversation_id' => null];
ConversationModel::where('user_id', $user['id'])->whereNull('deleted_at')->whereNull('model_id')->update($reset);
ConversationModel::where('user_id', $user['id'])->whereNull('deleted_at')->where('model_id', '<>', $guestModelId)->update($reset);
}
$page = max(1, (int) $this->request->get('page', 1));
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
@@ -51,6 +58,12 @@ class Conversation extends BaseApi
$modelId = null;
}
if (GuestAccessService::isGuest($user)) {
$modelId = GuestAccessService::assertModelAllowed($user, $modelId);
} elseif ($modelId && !AiModel::where('id', $modelId)->where('enabled', 1)->find()) {
return $this->error('所选模型不存在或已停用', 422);
}
$conversation = ConversationModel::create([
'user_id' => $user['id'],
'title' => trim($input['title'] ?? '新对话'),
@@ -94,6 +107,13 @@ class Conversation extends BaseApi
$data['model_id'] = (int) $mid;
}
if (GuestAccessService::isGuest($user)) {
$requestedModelId = $data['model_id'] ?: null;
$data['model_id'] = GuestAccessService::assertModelAllowed($user, $requestedModelId);
} elseif ($data['model_id'] && !AiModel::where('id', $data['model_id'])->where('enabled', 1)->find()) {
return $this->error('所选模型不存在或已停用', 422);
}
$currentModelId = $conversation->model_id === null
? null
: (int) $conversation->model_id;
@@ -103,6 +123,14 @@ class Conversation extends BaseApi
}
}
if (GuestAccessService::isGuest($user) && !array_key_exists('model_id', $input)) {
$guestModelId = GuestAccessService::modelId();
if ((int) $conversation->model_id !== $guestModelId) {
$data['model_id'] = $guestModelId;
$data['external_conversation_id'] = null;
}
}
if (empty($data)) {
return $this->error('无更新内容');
}
+20
View File
@@ -5,6 +5,7 @@ namespace app\controller\api;
use app\model\AiModel;
use app\service\AgentCatalog;
use app\service\CosyVoiceService;
use app\service\GuestAccessService;
use app\service\SettingsService;
class Settings extends BaseApi
@@ -20,6 +21,7 @@ class Settings extends BaseApi
return $this->success([
'site_name' => SettingsService::get('site_name', 'AI Chat'),
'allow_register' => $allow === true || $allow === 'true',
'registration_requires_invite' => true,
'features' => SettingsService::getFeatures(),
'voice_persona' => CosyVoiceService::publicPersona(),
]);
@@ -27,6 +29,20 @@ class Settings extends BaseApi
public function models()
{
$user = $this->authUser();
if (GuestAccessService::isGuest($user)) {
$model = GuestAccessService::model();
return $this->success([[
'id' => (int) $model->id,
'name' => $model->name,
'provider' => $model->provider,
'model_id' => $model->model_id,
'is_default' => 1,
'support_context' => (int) $model->support_context,
'support_image' => 0,
]]);
}
$list = AiModel::where('enabled', 1)
->field('id,name,provider,model_id,is_default,support_context,support_image')
->order('sort_order,id')
@@ -37,6 +53,10 @@ class Settings extends BaseApi
public function agents()
{
if (GuestAccessService::isGuest($this->authUser())) {
return $this->success([]);
}
return $this->success(AgentCatalog::publicList());
}
}
File diff suppressed because it is too large Load Diff
+39 -5
View File
@@ -118,12 +118,46 @@ class Upload extends BaseApi
}
$mime = $upload->mime_type ?: (@mime_content_type($path) ?: 'application/octet-stream');
return response(file_get_contents($path), 200, [
'Content-Type' => $mime,
'Content-Length' => (string) filesize($path),
$size = (int) filesize($path);
$headers = [
'Content-Type' => $mime,
'Content-Length' => (string) $size,
'Accept-Ranges' => 'bytes',
'Cache-Control' => 'public, max-age=604800',
]);
];
$range = trim((string) $this->request->header('range', ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
$start = $matches[1] === '' ? 0 : (int) $matches[1];
$end = $matches[2] === '' ? $size - 1 : (int) $matches[2];
if ($matches[1] === '' && $matches[2] !== '') {
$length = min($size, (int) $matches[2]);
$start = $size - $length;
$end = $size - 1;
}
if ($start < 0 || $start >= $size || $end < $start) {
return response('', 416, [
'Content-Range' => 'bytes */' . $size,
'Accept-Ranges' => 'bytes',
]);
}
$end = min($end, $size - 1);
$length = $end - $start + 1;
$handle = fopen($path, 'rb');
if ($handle === false || fseek($handle, $start) !== 0) {
if (is_resource($handle)) {
fclose($handle);
}
throw new HttpResponseException(response('文件读取失败', 500));
}
$content = (string) fread($handle, $length);
fclose($handle);
$headers['Content-Length'] = (string) strlen($content);
$headers['Content-Range'] = "bytes {$start}-{$end}/{$size}";
return response($content, 206, $headers);
}
return response(file_get_contents($path), 200, $headers);
}
/**
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class InvitationCode extends Model
{
protected $name = 'invitation_codes';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\model;
use think\Model;
class VideoCharacter extends Model
{
protected $name = 'video_characters';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $type = [
'is_locked' => 'boolean',
];
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class VideoEpisode extends Model
{
protected $name = 'video_episodes';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class VideoProject extends Model
{
protected $name = 'video_projects';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\model;
use think\Model;
class VideoShot extends Model
{
protected $name = 'video_shots';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $type = [
'meta' => 'json',
];
}
@@ -0,0 +1,76 @@
<?php
namespace app\service;
use app\model\AiModel;
use think\exception\HttpResponseException;
class GuestAccessService
{
public const MODEL_NAME = 'qwen3.6';
public static function isGuest(array $user): bool
{
return !empty($user['is_guest']);
}
public static function model(): AiModel
{
$models = AiModel::where('enabled', 1)->order('sort_order')->order('id')->select();
foreach ($models as $model) {
$identity = strtolower(trim((string) ($model->model_id ?: $model->name)));
$name = strtolower(trim((string) $model->name));
if (str_starts_with($identity, self::MODEL_NAME) || str_starts_with($name, self::MODEL_NAME)) {
return $model;
}
}
self::abort('游客专用模型 qwen3.6 尚未启用,请联系管理员', 503);
}
public static function modelId(): int
{
return (int) self::model()->id;
}
public static function assertModelAllowed(array $user, ?int $modelId): int
{
if (!self::isGuest($user)) {
return $modelId ?: 0;
}
$guestModelId = self::modelId();
if ($modelId && $modelId !== $guestModelId) {
self::abort('游客仅可使用 qwen3.6 模型,登录后可使用全部模型', 403);
}
return $guestModelId;
}
public static function assertTextChatOnly(array $user, array $attachments, string $agentId, string $imageTool, bool $voiceMode): void
{
if (!self::isGuest($user)) {
return;
}
if ($attachments || $agentId !== '' || $imageTool !== '' || $voiceMode) {
self::abort('游客仅支持 qwen3.6 文本对话,登录后可使用全部模型和工具', 403);
}
}
public static function assertAccountRequired(array $user, string $feature): void
{
if (self::isGuest($user)) {
self::abort("游客不能使用{$feature},请先登录", 403);
}
}
private static function abort(string $message, int $httpCode): never
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], $httpCode));
}
}
+823
View File
@@ -0,0 +1,823 @@
<?php
namespace app\service;
use app\model\AiModel;
use app\model\UploadFile;
use app\model\VideoProject;
use app\model\VideoShot;
/**
* MiniMax H3 原生 ComfyUI API 接入。
*
* 只使用 ComfyUI core 节点,提供无参考的 FL2VA 和带角色图的 REF2VA 两条工作流。
*/
class MiniMaxH3Service
{
public const WORKFLOW_VERSION = 'minimax-h3-joint-av-v6';
private const FL2VA_MODEL = 'minimax_h3_fl2va_pruned_int8_convrot.safetensors';
private const REF2VA_MODEL = 'minimax_h3_ref2va_pruned_int8_convrot.safetensors';
private const TEXT_ENCODER = 'qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors';
private const VIDEO_VAE = 'minimax_h3_video_vae_fp16.safetensors';
private const AUDIO_VAE = 'minimax_h3_audio_vae_fp32.safetensors';
public static function workflowManifest(): array
{
return [
'version' => self::WORKFLOW_VERSION,
'fps' => 24,
'supported_shot_durations' => [5, 10],
'frames_by_duration' => ['5' => 124, '10' => 243],
'trained_frame_range' => [124, 362],
'fl2va_model' => self::FL2VA_MODEL,
'ref2va_model' => self::REF2VA_MODEL,
'text_encoder' => self::TEXT_ENCODER,
'video_vae' => self::VIDEO_VAE,
'audio_vae' => self::AUDIO_VAE,
'text_render_policy' => 'clean_surface_plus_exact_ass_postprocess',
'synced_project_settings' => [
'aspect_ratio',
'quality',
'voice_language',
'show_subtitles',
'character_origin',
'screen_text_language',
'shot_duration_mode',
],
'nodes' => [
'UNETLoader',
'MiniMaxH3SigmaShift',
'CLIPLoader',
'VAELoader',
'MiniMaxH3ImageToVideo / MiniMaxH3ReferenceToVideo',
'ConditioningZeroOut',
'KSampler',
'LTXVSeparateAVLatent',
'VAEDecode',
'VAEDecodeAudio',
'CreateVideo',
'SaveVideo',
],
];
}
/** @return array<int,AiModel> 每个 ComfyUI 地址只保留一个工作节点。 */
public static function workers(): array
{
$models = AiModel::where('provider', 'comfy')
->where('enabled', 1)
->order('is_default', 'desc')
->order('sort_order')
->order('id')
->select();
$workers = [];
foreach ($models as $model) {
$endpoint = strtolower(self::baseUrl((string) $model->api_base_url));
if (!isset($workers[$endpoint])) {
$workers[$endpoint] = $model;
}
}
if (!$workers) {
throw new \RuntimeException('管理端尚未启用 ComfyUI 模型');
}
return array_values($workers);
}
public static function workerSummary(): array
{
try {
$workers = self::workers();
$count = count($workers);
return [
'configured_workers' => $count,
'effective_concurrency' => min(3, $count),
'mode' => $count > 1 ? 'multi_endpoint_parallel' : 'single_endpoint_serial',
'message' => $count > 1
? "已配置 {$count} 个独立 ComfyUI 地址,最多并发 3 个镜头"
: '当前只有 1 个 ComfyUI 地址;同地址任务按队列串行执行',
];
} catch (\Throwable $error) {
return [
'configured_workers' => 0,
'effective_concurrency' => 0,
'mode' => 'unavailable',
'message' => $error->getMessage(),
];
}
}
public static function model(?int $workerId = null): AiModel
{
if ($workerId !== null && $workerId > 0) {
$model = AiModel::where('provider', 'comfy')->where('id', $workerId)->find();
if ($model) {
return $model;
}
}
return self::workers()[0];
}
/**
* @param array<int,array<string,mixed>> $characters
* @return array{prompt_id:string,workflow_type:string,reference_count:int,continuity_applied:bool,audio_mode:string,workflow_version:string,worker_id:int,worker_name:string,applied_settings:array<string,mixed>}
*/
public static function submitShot(
VideoShot $shot,
VideoProject $project,
array $characters,
?array $preparedReferenceFiles = null,
?int $continuityUploadId = null,
?AiModel $worker = null
): array
{
$model = $worker ?? self::model();
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = (string) ($model->api_key ?? '');
$referenceFiles = $preparedReferenceFiles
?? self::uploadReferenceFiles($project, $characters, $baseUrl, $apiKey);
$continuityFile = $continuityUploadId
? self::uploadContinuityFrame($project, $continuityUploadId, $baseUrl, $apiKey)
: null;
$characterReferenceCount = count($referenceFiles);
$language = VideoDubService::normalizeLanguage((string) ($project->voice_language ?? 'zh-CN'));
$shotMeta = is_array($shot->meta) ? $shot->meta : [];
$timeline = is_array($shotMeta['timeline'] ?? null) ? $shotMeta['timeline'] : [];
$audioMode = VideoDubService::normalizeAudioMode((string) ($timeline['audio_mode'] ?? 'ambient_only'));
$workflowPrompt = self::audioDirective($language, $audioMode, $timeline)
. self::screenTextDirective(
(string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN),
$timeline
)
. (string) $shot->prompt;
$firstFrameFile = null;
if ($continuityFile !== null && $referenceFiles) {
// REF2VA 最多支持 9 张图,给真实连续帧固定保留最后一个槽位。
$referenceFiles = array_slice($referenceFiles, 0, 8);
$continuityPictureNo = count($referenceFiles) + 1;
$referenceFiles[] = $continuityFile;
$workflowPrompt = preg_replace('/。+$/u', '', $workflowPrompt) ?? $workflowPrompt;
$workflowPrompt .= "。<Picture {$continuityPictureNo}> 是上一镜头的真实结束帧;本镜头第一帧必须复现其人物位置、脸部、服装、动作相位、构图、背景、光向和色温,再从该动作自然继续,禁止重新起势或跳切。";
$workflowType = 'ref2va-continuity';
} elseif ($continuityFile !== null) {
$firstFrameFile = $continuityFile;
$workflowPrompt = preg_replace('/。+$/u', '', $workflowPrompt) ?? $workflowPrompt;
$workflowPrompt .= '。输入首帧是上一镜头的真实结束帧;必须从这张画面无缝继续人物动作、视线和摄影机运动,禁止改变脸、服装、背景、光线或重新起势。';
$workflowType = 'i2v-continuity';
} else {
$workflowType = $referenceFiles ? 'ref2va' : 'fl2va';
}
[$width, $height, $steps] = self::generationPreset(
(string) $project->aspect_ratio,
(string) $project->quality
);
$shotDuration = in_array((int) $shot->duration_seconds, [5, 10], true)
? (int) $shot->duration_seconds
: 5;
$frameLength = self::frameLengthForDuration($shotDuration);
$workflow = self::buildWorkflow([
'workflow_type' => $workflowType,
'prompt' => $workflowPrompt,
'width' => $width,
'height' => $height,
'length' => $frameLength,
'steps' => $steps,
'seed' => (int) ($shot->seed ?: random_int(1, PHP_INT_MAX)),
'reference_files' => $referenceFiles,
'first_frame_file' => $firstFrameFile,
'ref_image_size' => ($shotMeta['identity_boost'] ?? false) ? 'max' : 'match',
]);
return [
'prompt_id' => self::queuePrompt($baseUrl, $workflow, $apiKey),
'workflow_type' => $workflowType,
'reference_count' => $characterReferenceCount,
'continuity_applied' => $continuityFile !== null,
'audio_mode' => $audioMode,
'workflow_version' => self::WORKFLOW_VERSION,
'worker_id' => (int) $model->id,
'worker_name' => (string) $model->name,
'applied_settings' => [
'aspect_ratio' => (string) $project->aspect_ratio,
'quality' => (string) $project->quality,
'voice_language' => $language,
'show_subtitles' => (bool) ($project->show_subtitles ?? false),
'character_origin' => ShortDramaPlannerService::normalizeCharacterOrigin(
(string) ($project->character_origin ?? '')
),
'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage(
(string) ($project->screen_text_language ?? '')
),
'shot_duration_mode' => ShortDramaPlannerService::normalizeShotDurationMode(
(string) ($project->shot_duration_mode ?? '')
),
'width' => $width,
'height' => $height,
'steps' => $steps,
'shot_duration_seconds' => $shotDuration,
'frame_length' => $frameLength,
],
];
}
private static function screenTextDirective(string $language, array $timeline): string
{
$language = ShortDramaPlannerService::normalizeScreenTextLanguage($language);
$screenText = trim((string) ($timeline['screen_text'] ?? ''));
if ($language === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE || $screenText === '') {
return 'SCENE TEXT POLICY: render no readable glyphs. ';
}
$label = $language === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_EN_US
? 'English'
: 'Simplified Chinese';
return "SCENE TEXT POLICY: reserve a clean, stable, unobstructed surface for {$label} scene text, but draw no glyphs inside H3. Exact post-render text is: “{$screenText}”. The compositor will burn it in; do not invent pseudo-letters or symbols. ";
}
private static function audioDirective(string $language, string $audioMode, array $timeline): string
{
$noText = 'ABSOLUTELY NO visible text, subtitles, captions, speech bubbles, typography, letters, numbers, logos, watermarks or interface. ';
if ($language === VideoDubService::LANG_NONE || $audioMode === VideoDubService::AUDIO_AMBIENT) {
return 'MINIMAX H3 JOINT AUDIO-VIDEO SHOT. Generate continuous synchronized scene ambience and physical action sounds only. '
. 'NO dialogue, narration, singing, yelling, mumbling, pseudo-language or any human voice. Every visible person keeps a naturally closed mouth and never performs speaking mouth motion. '
. $noText;
}
if ($audioMode === VideoDubService::AUDIO_SCENE) {
$soundEffects = is_array($timeline['sound_effects'] ?? null)
? array_values(array_filter(array_map('trim', $timeline['sound_effects'])))
: [];
$soundRule = $soundEffects
? 'Generate these exact synchronized diegetic sounds: ' . implode('; ', $soundEffects) . '. '
: 'Generate only synchronized diegetic ambience and physical action sounds visible in the shot. ';
return 'MINIMAX H3 NATIVE JOINT AUDIO-VIDEO SCENE-SOUND SHOT. ' . $soundRule
. 'These are environmental/action sounds, never spoken words. NO dialogue, narration, off-screen voice, singing, yelling, crying speech, mumbling, gibberish or pseudo-language. '
. 'Every visible person keeps a naturally closed mouth and never performs speaking mouth motion. '
. $noText;
}
$speaker = trim((string) ($timeline['dialogue_speaker'] ?? '主角')) ?: '主角';
$dialogue = trim((string) ($timeline['dialogue'] ?? ''));
$delivery = trim((string) ($timeline['dialogue_delivery'] ?? '自然')) ?: '自然';
if ($audioMode === VideoDubService::AUDIO_NARRATION) {
return 'MINIMAX H3 VISUAL SHOT FOR POST-DUBBED NARRATION. Do not generate the narration or any other human voice; the raw H3 soundtrack will be discarded. '
. 'All visible people keep their mouths naturally closed and never lip-sync, yell or perform speaking motion. '
. 'NO visible character speech, NO off-screen speech, NO singing, NO gibberish and NO pseudo-language. '
. $noText;
}
$gender = trim((string) ($timeline['speaker_gender'] ?? 'auto'));
$genderRule = $gender === 'auto'
? 'The speaking voice must match the visible speakers actual sex, apparent age and identity.'
: "Use a {$gender} voice matching the visible speakers age and identity.";
return "MINIMAX H3 NATIVE JOINT AUDIO-VIDEO CHARACTER DIALOGUE. The visible {$speaker}, and nobody else, speaks exact standard Mandarin Chinese: “{$dialogue}”. "
. "Delivery: {$delivery}. {$genderRule} The speaker starts with a closed mouth, opens the mouth only for this exact line with frame-accurate natural lip synchronization, then closes the mouth. "
. 'NO narrator, NO off-screen voice, NO second speaker, NO voice/sex mismatch, NO extra words, NO repeated words, NO gibberish and NO pseudo-language. Keep synchronized scene ambience and action sounds. '
. $noText;
}
/**
* 每次整集提交只上传一次角色图,所有镜头复用同一个 ComfyUI input 文件。
*
* @param array<int,array<string,mixed>> $characters
* @return string[]
*/
public static function prepareReferenceFiles(
VideoProject $project,
array $characters,
?AiModel $worker = null
): array
{
$model = $worker ?? self::model();
return self::uploadReferenceFiles(
$project,
$characters,
self::baseUrl($model->api_base_url),
(string) ($model->api_key ?? '')
);
}
/** @return string[] */
private static function uploadReferenceFiles(
VideoProject $project,
array $characters,
string $baseUrl,
string $apiKey
): array {
$referenceFiles = [];
foreach (array_slice($characters, 0, 9) as $index => $character) {
$uploadId = (int) ($character['reference_upload_id'] ?? 0);
if ($uploadId <= 0) {
continue;
}
$upload = UploadFile::where('id', $uploadId)
->where('user_id', (int) $project->user_id)
->where('file_type', 'image')
->find();
if (!$upload) {
continue;
}
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
if (!is_file($path)) {
continue;
}
$referenceFiles[] = self::uploadInputImage(
$baseUrl,
$path,
$apiKey,
'character_' . ((int) $index + 1)
);
}
return $referenceFiles;
}
private static function uploadContinuityFrame(
VideoProject $project,
int $uploadId,
string $baseUrl,
string $apiKey
): ?string {
$upload = UploadFile::where('id', $uploadId)
->where('user_id', (int) $project->user_id)
->where('file_type', 'image')
->find();
if (!$upload) {
return null;
}
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
if (!is_file($path)) {
return null;
}
return self::uploadInputImage($baseUrl, $path, $apiKey, 'continuity');
}
/**
* @return array{state:string,message:string,files:array,error:?string}
*/
public static function inspect(string $promptId, ?int $workerId = null): array
{
$model = self::model($workerId);
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = (string) ($model->api_key ?? '');
$history = self::getJson($baseUrl . '/history/' . rawurlencode($promptId), $apiKey);
if (isset($history[$promptId])) {
$entry = $history[$promptId];
$status = is_array($entry['status'] ?? null) ? $entry['status'] : [];
foreach (($status['messages'] ?? []) as $message) {
if (($message[0] ?? '') !== 'execution_error') {
continue;
}
$detail = $message[1]['exception_message']
?? json_encode($message[1] ?? [], JSON_UNESCAPED_UNICODE);
return [
'state' => 'error',
'message' => '视频生成失败',
'files' => [],
'error' => (string) $detail,
];
}
$files = self::collectVideoFiles($entry['outputs'] ?? []);
if ($files) {
return [
'state' => 'done',
'message' => '视频镜头生成完成',
'files' => $files,
'error' => null,
];
}
if (!empty($status['completed']) || ($status['status_str'] ?? '') === 'success') {
return [
'state' => 'error',
'message' => '任务完成但没有找到 MP4 输出',
'files' => [],
'error' => 'SaveVideo 未返回可下载文件',
];
}
}
$queue = self::getJson($baseUrl . '/queue', $apiKey);
foreach (($queue['queue_running'] ?? []) as $item) {
if ((string) ($item[1] ?? '') === $promptId) {
return ['state' => 'running', 'message' => '正在渲染镜头', 'files' => [], 'error' => null];
}
}
foreach (array_values($queue['queue_pending'] ?? []) as $index => $item) {
if ((string) ($item[1] ?? '') === $promptId) {
return [
'state' => 'queued',
'message' => $index > 0 ? "排队中,前面还有 {$index} 个任务" : '即将开始渲染',
'files' => [],
'error' => null,
];
}
}
return [
'state' => 'missing',
'message' => 'ComfyUI 队列中未找到任务,正在确认是否需要自动重试',
'files' => [],
'error' => null,
];
}
/**
* @return array{id:int,url:string,name:string,mime:string,size:int,path:string}
*/
public static function storeVideo(array $file, int $userId, ?int $workerId = null): array
{
$model = self::model($workerId);
$baseUrl = self::baseUrl($model->api_base_url);
$apiKey = (string) ($model->api_key ?? '');
$filename = basename((string) ($file['filename'] ?? ''));
if ($filename === '') {
throw new \RuntimeException('ComfyUI 视频文件名为空');
}
$query = http_build_query([
'filename' => $filename,
'subfolder' => (string) ($file['subfolder'] ?? ''),
'type' => (string) ($file['type'] ?? 'output'),
]);
$binary = self::getBinary($baseUrl . '/view?' . $query, $apiKey);
if ($binary === null || $binary === '') {
throw new \RuntimeException('下载 ComfyUI 视频失败');
}
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($extension, ['mp4', 'webm', 'mov'], true)) {
$extension = 'mp4';
}
$subdir = date('Y/m/d');
$storedBase = 'h3_' . uniqid('', true) . '.' . $extension;
$relativePath = $subdir . '/' . $storedBase;
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
throw new \RuntimeException('无法创建视频存储目录');
}
$fullPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
if (file_put_contents($fullPath, $binary) === false) {
throw new \RuntimeException('保存生成视频失败');
}
$mime = @mime_content_type($fullPath) ?: ($extension === 'webm' ? 'video/webm' : 'video/mp4');
$size = (int) filesize($fullPath);
$upload = UploadFile::create([
'user_id' => $userId,
'original_name' => 'short_drama_shot.' . $extension,
'stored_name' => $storedBase,
'file_path' => $relativePath,
'mime_type' => $mime,
'file_size' => $size,
'file_type' => 'video',
]);
return [
'id' => (int) $upload->id,
'url' => '/api/uploads/' . rawurlencode($storedBase),
'name' => (string) $upload->original_name,
'mime' => $mime,
'size' => $size,
'path' => $fullPath,
];
}
/**
* @param array{workflow_type:string,prompt:string,width:int,height:int,length:int,steps:int,seed:int,reference_files:array,first_frame_file:?string,ref_image_size:string} $options
*/
public static function buildWorkflow(array $options): array
{
$isReference = str_starts_with($options['workflow_type'], 'ref2va')
&& !empty($options['reference_files']);
$workflow = [
'1' => [
'_meta' => ['title' => 'MiniMax H3 FL2VA / REF2VA 模型'],
'class_type' => 'UNETLoader',
'inputs' => [
'unet_name' => $isReference ? self::REF2VA_MODEL : self::FL2VA_MODEL,
'weight_dtype' => 'default',
],
],
'2' => [
'_meta' => ['title' => 'H3 视频/音频联合采样时间表'],
'class_type' => 'MiniMaxH3SigmaShift',
'inputs' => ['model' => ['1', 0], 'shift_video' => 12.0, 'shift_audio' => 3.0],
],
'3' => [
'_meta' => ['title' => 'Qwen3-VL H3 文本编码器'],
'class_type' => 'CLIPLoader',
'inputs' => ['clip_name' => self::TEXT_ENCODER, 'type' => 'minimax', 'device' => 'default'],
],
'4' => [
'_meta' => ['title' => 'H3 视频 VAE'],
'class_type' => 'VAELoader',
'inputs' => ['vae_name' => self::VIDEO_VAE],
],
'5' => [
'_meta' => ['title' => 'H3 音频 VAE'],
'class_type' => 'VAELoader',
'inputs' => ['vae_name' => self::AUDIO_VAE],
],
];
if ($isReference) {
$conditionInputs = [
'clip' => ['3', 0],
'vae' => ['4', 0],
'audio_vae' => ['5', 0],
'prompt' => (string) $options['prompt'],
'width' => (int) $options['width'],
'height' => (int) $options['height'],
'length' => (int) $options['length'],
'ref_image_size' => $options['ref_image_size'] === 'max' ? 'max' : 'match',
];
foreach (array_values($options['reference_files']) as $index => $filename) {
$nodeId = (string) (20 + $index);
$workflow[$nodeId] = [
'_meta' => ['title' => '角色/连续性参考图 ' . ($index + 1)],
'class_type' => 'LoadImage',
'inputs' => ['image' => (string) $filename],
];
// V3 Autogrow inputs use dotted API keys: <group>.<generated input>.
// The visible character numbering remains one-based in prompts (<Picture 1>),
// while TemplatePrefix itself is zero-based (ref_image_0, ref_image_1, ...).
$conditionInputs['ref_images.ref_image_' . $index] = [$nodeId, 0];
}
$workflow['6'] = [
'_meta' => ['title' => 'H3 REF2VA 联合音画条件'],
'class_type' => 'MiniMaxH3ReferenceToVideo',
'inputs' => $conditionInputs,
];
} else {
$imageToVideoInputs = [
'clip' => ['3', 0],
'vae' => ['4', 0],
'prompt' => (string) $options['prompt'],
'width' => (int) $options['width'],
'height' => (int) $options['height'],
'length' => (int) $options['length'],
];
if (!empty($options['first_frame_file'])) {
$workflow['20'] = [
'_meta' => ['title' => '上一镜头真实尾帧'],
'class_type' => 'LoadImage',
'inputs' => ['image' => (string) $options['first_frame_file']],
];
$imageToVideoInputs['first_frame'] = ['20', 0];
}
$workflow['6'] = [
'_meta' => ['title' => 'H3 FL2VA / 首帧续拍联合音画条件'],
'class_type' => 'MiniMaxH3ImageToVideo',
'inputs' => $imageToVideoInputs,
];
}
$workflow += [
'7' => [
'_meta' => ['title' => '零负向条件'],
'class_type' => 'ConditioningZeroOut',
'inputs' => ['conditioning' => ['6', 0]],
],
'8' => [
'_meta' => ['title' => 'H3 联合视频+音频采样器'],
'class_type' => 'KSampler',
'inputs' => [
'model' => ['2', 0],
'seed' => (int) $options['seed'],
'steps' => (int) $options['steps'],
'cfg' => 1.0,
'sampler_name' => 'euler',
'scheduler' => 'simple',
'positive' => ['6', 0],
'negative' => ['7', 0],
'latent_image' => ['6', 1],
'denoise' => 1.0,
],
],
'9' => [
'_meta' => ['title' => '拆分联合视频/音频潜变量'],
'class_type' => 'LTXVSeparateAVLatent',
'inputs' => ['av_latent' => ['8', 0]],
],
'10' => [
'_meta' => ['title' => '解码视频画面'],
'class_type' => 'VAEDecode',
'inputs' => ['samples' => ['9', 0], 'vae' => ['4', 0]],
],
'11' => [
'_meta' => ['title' => '解码 H3 原生同步音轨'],
'class_type' => 'VAEDecodeAudio',
'inputs' => ['samples' => ['9', 1], 'vae' => ['5', 0]],
],
'12' => [
'_meta' => ['title' => '24fps 联合音画封装'],
'class_type' => 'CreateVideo',
'inputs' => ['images' => ['10', 0], 'fps' => 24.0, 'audio' => ['11', 0], 'bit_depth' => 8],
],
'13' => [
'_meta' => ['title' => '保存 H3 联合音画 MP4'],
'class_type' => 'SaveVideo',
'inputs' => [
'video' => ['12', 0],
'filename_prefix' => 'short_drama/H3_AV_V2_',
'format' => 'mp4',
'codec' => 'auto',
],
],
];
return $workflow;
}
/** @return array{int,int,int} */
private static function generationPreset(string $aspectRatio, string $quality): array
{
$portrait = $aspectRatio !== '16:9';
if ($quality === 'high') {
return $portrait ? [768, 1344, 16] : [1344, 768, 16];
}
if ($quality === 'standard') {
return $portrait ? [576, 1024, 12] : [1024, 576, 12];
}
return $portrait ? [512, 896, 8] : [896, 512, 8];
}
private static function frameLengthForDuration(int $durationSeconds): int
{
// H3 使用 17k+5 帧网格:124 帧约 5.17 秒,243 帧约 10.13 秒。
return $durationSeconds >= 10 ? 243 : 124;
}
private static function uploadInputImage(
string $baseUrl,
string $path,
string $apiKey,
string $purpose
): string {
$imageInfo = @getimagesize($path);
if (!is_array($imageInfo) || empty($imageInfo['mime'])) {
throw new \RuntimeException('角色参考图不是有效图片');
}
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (!in_array($extension, ['png', 'jpg', 'jpeg', 'webp'], true)) {
$extension = $imageInfo['mime'] === 'image/jpeg' ? 'jpg' : 'png';
}
$subfolder = 'short_drama/' . date('Ymd');
$uploadName = $purpose . '_' . bin2hex(random_bytes(8)) . '.' . $extension;
$ch = curl_init($baseUrl . '/upload/image');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'image' => new \CURLFile($path, (string) $imageInfo['mime'], $uploadName),
'type' => 'input',
'subfolder' => $subfolder,
'overwrite' => 'true',
],
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
throw new \RuntimeException('上传角色参考图到 ComfyUI 失败: ' . ($error ?: 'HTTP ' . $httpCode));
}
$data = json_decode((string) $response, true);
$name = trim((string) ($data['name'] ?? $uploadName));
$storedSubfolder = trim((string) ($data['subfolder'] ?? $subfolder), '/\\');
return $storedSubfolder === '' ? $name : $storedSubfolder . '/' . $name;
}
private static function queuePrompt(string $baseUrl, array $workflow, string $apiKey): string
{
$prompt = new \stdClass();
foreach ($workflow as $id => $node) {
$prompt->{(string) $id} = $node;
}
$body = json_encode([
'prompt' => $prompt,
'client_id' => 'short-drama-' . bin2hex(random_bytes(6)),
], JSON_UNESCAPED_UNICODE);
if ($body === false) {
throw new \RuntimeException('H3 工作流编码失败');
}
$ch = curl_init($baseUrl . '/prompt');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], self::authHeaders($apiKey)),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$data = json_decode((string) $response, true);
if ($response === false || $httpCode !== 200 || !empty($data['node_errors'])) {
$detail = $data['error']['message'] ?? $data['error'] ?? ($error ?: 'HTTP ' . $httpCode);
if (is_array($detail)) {
$detail = json_encode($detail, JSON_UNESCAPED_UNICODE);
}
if (!empty($data['node_errors'])) {
$detail .= '' . json_encode($data['node_errors'], JSON_UNESCAPED_UNICODE);
}
throw new \RuntimeException('ComfyUI 拒绝 H3 工作流: ' . $detail);
}
$promptId = trim((string) ($data['prompt_id'] ?? ''));
if ($promptId === '') {
throw new \RuntimeException('ComfyUI 未返回视频任务 ID');
}
return $promptId;
}
private static function collectVideoFiles(array $outputs): array
{
$files = [];
$walk = function (mixed $value) use (&$files, &$walk): void {
if (!is_array($value)) {
return;
}
if (isset($value['filename'])) {
$extension = strtolower(pathinfo((string) $value['filename'], PATHINFO_EXTENSION));
if (in_array($extension, ['mp4', 'webm', 'mov'], true)) {
$files[] = $value;
}
}
foreach ($value as $child) {
if (is_array($child)) {
$walk($child);
}
}
};
$walk($outputs);
$unique = [];
foreach ($files as $file) {
$key = ($file['type'] ?? 'output') . '|' . ($file['subfolder'] ?? '') . '|' . $file['filename'];
$unique[$key] = $file;
}
return array_values($unique);
}
private static function getJson(string $url, string $apiKey): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPGET => true,
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode >= 400) {
return [];
}
$data = json_decode((string) $response, true);
return is_array($data) ? $data : [];
}
private static function getBinary(string $url, string $apiKey): ?string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPGET => true,
CURLOPT_HTTPHEADER => self::authHeaders($apiKey),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 180,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $response !== false && $httpCode === 200 ? $response : null;
}
private static function baseUrl(?string $url): string
{
$url = rtrim((string) $url, '/');
if ($url === '') {
throw new \InvalidArgumentException('未配置 ComfyUI 地址');
}
return $url;
}
private static function authHeaders(string $apiKey): array
{
return trim($apiKey) === '' ? [] : ['Authorization: Bearer ' . $apiKey];
}
}
+22
View File
@@ -47,6 +47,17 @@ class PermissionCatalog
['code' => 'btn:user:delete', 'name' => '删除用户', 'type' => 'btn'],
],
],
[
'code' => 'menu:guests',
'name' => '访客管理',
'type' => 'menu',
'path' => '/guests',
'icon' => 'visitors',
'children' => [
['code' => 'btn:guest:status', 'name' => '启用/禁用访客', 'type' => 'btn'],
['code' => 'btn:guest:delete', 'name' => '删除访客', 'type' => 'btn'],
],
],
[
'code' => 'menu:departments',
'name' => '部门管理',
@@ -59,6 +70,17 @@ class PermissionCatalog
['code' => 'btn:dept:delete', 'name' => '删除部门', 'type' => 'btn'],
],
],
[
'code' => 'menu:invitations',
'name' => '邀请码管理',
'type' => 'menu',
'path' => '/invitations',
'icon' => 'ticket',
'children' => [
['code' => 'btn:invitation:create', 'name' => '生成邀请码', 'type' => 'btn'],
['code' => 'btn:invitation:revoke', 'name' => '作废邀请码', 'type' => 'btn'],
],
],
[
'code' => 'menu:roles',
'name' => '角色管理',
+1 -1
View File
@@ -51,7 +51,7 @@ class PermissionService
public static function canUpload(array $user, string $type): bool
{
if (!empty($user['is_guest']) && $type === 'image') {
if (!empty($user['is_guest'])) {
return false;
}
+16 -12
View File
@@ -6,6 +6,20 @@ use app\model\SystemSetting;
class SettingsService
{
private const DEFAULT_FEATURES = [
'markdown' => true,
'image' => true,
'video' => true,
'voice' => true,
'document' => true,
'emoji' => true,
'upload_image' => true,
'upload_video' => true,
'upload_file' => true,
'paste_image' => true,
'short_drama' => true,
];
public static function get(string $key, mixed $default = null): mixed
{
$row = SystemSetting::where('setting_key', $key)->find();
@@ -36,18 +50,8 @@ class SettingsService
public static function getFeatures(): array
{
return self::get('features', [
'markdown' => true,
'image' => true,
'video' => true,
'voice' => true,
'document' => true,
'emoji' => true,
'upload_image' => true,
'upload_video' => true,
'upload_file' => true,
'paste_image' => true,
]);
$stored = self::get('features', []);
return array_replace(self::DEFAULT_FEATURES, is_array($stored) ? $stored : []);
}
public static function isFeatureEnabled(string $feature): bool
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
<?php
namespace app\service;
use app\model\UploadFile;
/**
* 短剧确定性配音:不再依赖 H3 原生音频猜测语言。
*/
class VideoDubService
{
public const LANG_MANDARIN = 'zh-CN';
public const LANG_NONE = 'none';
public const LANG_NATIVE = 'native';
public const AUDIO_CHARACTER = 'character_dialogue';
public const AUDIO_NARRATION = 'narration';
public const AUDIO_SCENE = 'scene_sound';
public const AUDIO_AMBIENT = 'ambient_only';
public const AUDIO_POST_TTS = 'post_tts_narration';
public const POLICY_VERSION = 'h3-joint-av-v5';
public static function normalizeLanguage(?string $language): string
{
return in_array($language, [self::LANG_MANDARIN, self::LANG_NONE, self::LANG_NATIVE], true)
? (string) $language
: self::LANG_MANDARIN;
}
public static function label(string $language): string
{
return match (self::normalizeLanguage($language)) {
self::LANG_NONE => '无配音',
self::LANG_NATIVE => 'H3 原生音轨',
default => '普通话(H3 原生口型同步)',
};
}
public static function normalizeAudioMode(?string $mode): string
{
return in_array($mode, [
self::AUDIO_CHARACTER,
self::AUDIO_NARRATION,
self::AUDIO_SCENE,
self::AUDIO_AMBIENT,
self::AUDIO_POST_TTS,
], true) ? (string) $mode : self::AUDIO_AMBIENT;
}
/**
* @return array{id:int,processed:bool,language:string}
*/
public static function replaceVoice(
int $videoUploadId,
int $userId,
string $dialogue,
string $language,
int $durationSeconds = 5,
string $placement = 'start',
array $audioPolicy = []
): array {
$language = self::normalizeLanguage($language);
$audioMode = self::normalizeAudioMode((string) ($audioPolicy['audio_mode'] ?? self::AUDIO_AMBIENT));
if ($language === self::LANG_NATIVE) {
return [
'id' => $videoUploadId,
'processed' => false,
'language' => $language,
'source' => 'h3_native',
'policy_version' => self::POLICY_VERSION,
];
}
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_CHARACTER) {
// 只有画面角色对白保留 H3 联合采样原声,确保人物性别、声音和口型来自同一次生成。
return [
'id' => $videoUploadId,
'processed' => false,
'language' => $language,
'source' => 'h3_native',
'policy_version' => self::POLICY_VERSION,
];
}
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_SCENE) {
// 场景音由 H3 与画面同次联合生成,才能让撞击、脚步、发动机等声音
// 精确跟随动作;规划器已明确禁止该类镜头产生任何人物声音。
return [
'id' => $videoUploadId,
'processed' => false,
'language' => $language,
'source' => 'h3_scene_sound',
'policy_version' => self::POLICY_VERSION,
];
}
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_AMBIENT) {
// H3 已经在同一次联合采样里生成与画面同步的空间底噪、动作声和
// 环境声。旧版用极低音量粉红噪声覆盖它,最终成片约 -71dB,
// 听感接近静音;保留原声既有声音,也不会破坏动作同步。
return [
'id' => $videoUploadId,
'processed' => false,
'language' => $language,
'source' => 'h3_ambient',
'policy_version' => self::POLICY_VERSION,
];
}
$video = UploadFile::where('id', $videoUploadId)
->where('user_id', $userId)
->where('file_type', 'video')
->find();
if (!$video) {
throw new \RuntimeException('无法读取待配音视频');
}
$videoPath = self::uploadPath($video);
if (!is_file($videoPath)) {
throw new \RuntimeException('待配音视频文件不存在');
}
$durationSeconds = max(1, min(30, $durationSeconds));
$pcmPath = null;
$speechTempoFilter = '';
$voiceDelayMs = 200;
if ($language === self::LANG_MANDARIN && $audioMode === self::AUDIO_NARRATION) {
$spokenText = self::spokenText($dialogue);
if ($spokenText !== '') {
$pcmPath = tempnam(sys_get_temp_dir(), 'short_drama_tts_');
if ($pcmPath === false) {
throw new \RuntimeException('无法创建普通话配音任务');
}
$pcm = self::synthesizeMandarin($spokenText);
if (file_put_contents($pcmPath, $pcm) === false) {
@unlink($pcmPath);
throw new \RuntimeException('无法保存普通话配音数据');
}
// 24 kHz、16 bit、单声道 PCM48000 bytes/s。超过镜头时长时加速,避免截断台词。
$pcmDuration = strlen($pcm) / 48000;
$targetDuration = max(0.8, $durationSeconds - 0.4);
$tempo = max(1.0, $pcmDuration / $targetDuration);
$speechTempoFilter = self::tempoFilter($tempo);
$speechDuration = $pcmDuration / $tempo;
if ($placement === 'end') {
$voiceDelayMs = max(200, (int) round(
max(0.0, $durationSeconds - $speechDuration - 0.3) * 1000
));
}
}
}
$subdir = date('Y/m/d');
$storedBase = 'dub_' . str_replace('-', '_', $language) . '_' . uniqid('', true) . '.mp4';
$relativePath = $subdir . '/' . $storedBase;
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
if ($pcmPath) {
@unlink($pcmPath);
}
throw new \RuntimeException('无法创建配音视频目录');
}
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
try {
$command = $audioMode === self::AUDIO_AMBIENT && $language !== self::LANG_NONE
? [
'ffmpeg', '-y', '-i', $videoPath,
'-f', 'lavfi', '-i', 'anoisesrc=color=pink:amplitude=0.006:r=48000',
'-filter_complex', '[1:a]highpass=f=80,lowpass=f=4800,volume=0.35,apad,atrim=0:' . $durationSeconds . '[amb]',
'-map', '0:v:0', '-map', '[amb]',
'-t', (string) $durationSeconds,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart', $outputPath,
]
: ($language === self::LANG_NONE || $pcmPath === null
? [
'ffmpeg', '-y', '-i', $videoPath,
'-f', 'lavfi', '-i', 'anullsrc=r=48000:cl=mono',
'-map', '0:v:0', '-map', '1:a:0',
'-t', (string) $durationSeconds,
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart', $outputPath,
]
: [
'ffmpeg', '-y', '-i', $videoPath,
'-f', 's16le', '-ar', '24000', '-ac', '1', '-i', (string) $pcmPath,
'-filter_complex', "[1:a]aresample=48000{$speechTempoFilter},volume=2.0,alimiter=limit=0.90:level=false,adelay={$voiceDelayMs},apad,atrim=0:{$durationSeconds}[dub]",
'-map', '0:v:0', '-map', '[dub]',
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart', '-shortest', $outputPath,
]);
[$exitCode, $error] = self::run($command);
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
@unlink($outputPath);
throw new \RuntimeException('普通话音轨合成失败: ' . mb_substr(trim($error), -500));
}
} finally {
if ($pcmPath) {
@unlink($pcmPath);
}
}
$upload = UploadFile::create([
'user_id' => $userId,
'original_name' => 'short_drama_' . $language . '.mp4',
'stored_name' => $storedBase,
'file_path' => $relativePath,
'mime_type' => 'video/mp4',
'file_size' => (int) filesize($outputPath),
'file_type' => 'video',
]);
return [
'id' => (int) $upload->id,
'processed' => true,
'language' => $language,
'source' => match (true) {
$language === self::LANG_NONE => 'silence',
$audioMode === self::AUDIO_AMBIENT => 'clean_ambient',
default => 'cosyvoice',
},
'policy_version' => self::POLICY_VERSION,
];
}
private static function tempoFilter(float $tempo): string
{
if ($tempo <= 1.0) {
return '';
}
$filters = [];
while ($tempo > 2.0) {
$filters[] = 'atempo=2.0';
$tempo /= 2.0;
}
$filters[] = 'atempo=' . number_format(max(1.0, $tempo), 4, '.', '');
return ',' . implode(',', $filters);
}
private static function synthesizeMandarin(string $text): string
{
$baseUrl = rtrim((string) config('short_drama.tts_base_url'), '/');
$promptWav = (string) config('short_drama.tts_prompt_wav');
$promptText = (string) config('short_drama.tts_prompt_text');
if ($baseUrl === '' || !is_file($promptWav)) {
throw new \RuntimeException('普通话配音服务尚未配置');
}
$ch = curl_init($baseUrl . '/inference_zero_shot');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'tts_text' => $text,
'prompt_text' => $promptText,
'prompt_wav' => new \CURLFile($promptWav, 'audio/wav', 'mandarin_reference.wav'),
],
CURLOPT_HTTPHEADER => ['Accept: application/octet-stream'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => false,
]);
$pcm = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($pcm === false || $httpCode < 200 || $httpCode >= 300) {
throw new \RuntimeException('普通话配音服务调用失败: ' . ($error ?: 'HTTP ' . $httpCode));
}
if (strlen((string) $pcm) < 4800) {
throw new \RuntimeException('普通话配音服务返回空音频');
}
if (strlen($pcm) % 2 !== 0) {
$pcm = substr($pcm, 0, -1);
}
return $pcm;
}
private static function spokenText(string $dialogue): string
{
$dialogue = trim($dialogue);
$dialogue = preg_replace('/^[^:]{1,16}[:]/u', '', $dialogue) ?? $dialogue;
return mb_substr(trim($dialogue), 0, 80);
}
private static function uploadPath(UploadFile $upload): string
{
return rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
}
/** @return array{int,string} */
private static function run(array $command): array
{
$pipes = [];
$process = @proc_open($command, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('服务器无法启动 FFmpeg');
}
fclose($pipes[0]);
stream_get_contents($pipes[1]);
fclose($pipes[1]);
$error = (string) stream_get_contents($pipes[2]);
fclose($pipes[2]);
return [proc_close($process), $error];
}
}
+351
View File
@@ -0,0 +1,351 @@
<?php
namespace app\service;
use app\model\UploadFile;
class VideoRenderService
{
/**
* 提取已生成镜头的最后一帧,作为下一镜头的真实视觉起点。
*/
public static function extractLastFrame(int $videoUploadId, int $userId): int
{
$video = UploadFile::where('id', $videoUploadId)
->where('user_id', $userId)
->where('file_type', 'video')
->find();
if (!$video) {
throw new \RuntimeException('无法读取上一镜头视频');
}
$videoPath = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $video->file_path);
if (!is_file($videoPath)) {
throw new \RuntimeException('上一镜头视频文件不存在');
}
$subdir = date('Y/m/d');
$storedBase = 'continuity_' . uniqid('', true) . '.jpg';
$relativePath = $subdir . '/' . $storedBase;
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
throw new \RuntimeException('无法创建镜头连续帧目录');
}
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
// H3 输出最后几帧有时会包含编码尾部黑帧,向前取 0.12 秒更稳定。
[$exitCode, $error] = self::run([
'ffmpeg', '-y', '-sseof', '-0.12', '-i', $videoPath,
'-frames:v', '1', '-q:v', '2', $outputPath,
]);
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
@unlink($outputPath);
throw new \RuntimeException('提取镜头尾帧失败: ' . mb_substr(trim($error), -400));
}
$upload = UploadFile::create([
'user_id' => $userId,
'original_name' => 'continuity_last_frame.jpg',
'stored_name' => $storedBase,
'file_path' => $relativePath,
'mime_type' => 'image/jpeg',
'file_size' => (int) filesize($outputPath),
'file_type' => 'image',
]);
return (int) $upload->id;
}
/**
* @param array<int,array<string,mixed>> $shots
*/
public static function concatenate(
array $shots,
int $userId,
bool $showSubtitles = false,
string $aspectRatio = '9:16',
string $screenTextLanguage = ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE,
string $quality = 'standard'
): int
{
$uploadIds = [];
$durations = [];
$renderShots = [];
foreach ($shots as $shot) {
$uploadId = (int) ($shot['output_upload_id'] ?? 0);
if ($uploadId <= 0) {
continue;
}
$uploadIds[] = $uploadId;
$durations[] = max(1, min(30, (int) ($shot['duration_seconds'] ?? 5)));
$renderShots[] = $shot;
}
if (!$uploadIds) {
throw new \RuntimeException('没有可合成的视频镜头');
}
$overlayDocument = self::overlayDocument(
$renderShots,
$durations,
$aspectRatio,
$showSubtitles,
$screenTextLanguage
);
if (count($uploadIds) === 1 && $overlayDocument === null) {
return $uploadIds[0];
}
$uploads = UploadFile::whereIn('id', $uploadIds)
->where('user_id', $userId)
->select()
->column(null, 'id');
$paths = [];
foreach ($uploadIds as $uploadId) {
$upload = $uploads[$uploadId] ?? null;
if (!$upload) {
throw new \RuntimeException('部分视频镜头文件已不存在');
}
$path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path);
if (!is_file($path)) {
throw new \RuntimeException('视频镜头文件无法读取');
}
$paths[] = $path;
}
$subdir = date('Y/m/d');
$storedBase = 'short_drama_' . uniqid('', true) . '.mp4';
$relativePath = $subdir . '/' . $storedBase;
$fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subdir);
if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) {
throw new \RuntimeException('无法创建短剧成片目录');
}
$outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase;
$subtitlePath = null;
if ($overlayDocument !== null) {
$subtitlePath = $fullDir . DIRECTORY_SEPARATOR . 'subtitle_' . uniqid('', true) . '.ass';
if (file_put_contents($subtitlePath, $overlayDocument) === false) {
throw new \RuntimeException('无法创建文字合成文件');
}
}
$command = ['ffmpeg', '-y'];
foreach ($paths as $path) {
$command[] = '-i';
$command[] = $path;
}
$filters = [];
$concatInputs = '';
[$targetWidth, $targetHeight] = self::renderSize($aspectRatio, $quality);
foreach ($durations as $index => $duration) {
// 不同批次或重试镜头可能使用不同质量档位。concat 要求每路画面和
// 音轨参数完全一致,因此先统一尺寸、SAR、帧率、像素格式和双声道。
$filters[] = "[{$index}:v:0]trim=duration={$duration},setpts=PTS-STARTPTS,"
. "scale={$targetWidth}:{$targetHeight}:force_original_aspect_ratio=decrease,"
. "pad={$targetWidth}:{$targetHeight}:(ow-iw)/2:(oh-ih)/2:color=black,"
. "setsar=1,fps=24,format=yuv420p[v{$index}]";
$filters[] = "[{$index}:a:0]aresample=48000,"
. "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,"
. "atrim=duration={$duration},asetpts=PTS-STARTPTS[a{$index}]";
$concatInputs .= "[v{$index}][a{$index}]";
}
if (count($paths) === 1) {
$videoOutput = '[v0]';
$audioOutput = '[a0]';
} else {
$filters[] = $concatInputs . 'concat=n=' . count($paths) . ':v=1:a=1[vconcat][aout]';
$videoOutput = '[vconcat]';
$audioOutput = '[aout]';
}
if ($subtitlePath !== null) {
$filters[] = $videoOutput . "ass=filename='" . self::escapeFilterPath($subtitlePath) . "'[vout]";
$videoOutput = '[vout]';
}
array_push(
$command,
'-filter_complex', implode(';', $filters),
'-map', $videoOutput, '-map', $audioOutput,
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '192k', '-ar', '48000',
'-movflags', '+faststart', $outputPath
);
[$exitCode, $error] = self::run($command);
if ($subtitlePath !== null) {
@unlink($subtitlePath);
}
if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) {
@unlink($outputPath);
throw new \RuntimeException('视频合成失败: ' . mb_substr(trim($error), -600));
}
$size = (int) filesize($outputPath);
$upload = UploadFile::create([
'user_id' => $userId,
'original_name' => 'short_drama_episode.mp4',
'stored_name' => $storedBase,
'file_path' => $relativePath,
'mime_type' => 'video/mp4',
'file_size' => $size,
'file_type' => 'video',
]);
return (int) $upload->id;
}
/**
* 生成后期 ASS 文字层。字幕与场景文字都不交给视频模型直接绘制,避免乱码、漂移和闪烁。
*
* @param array<int,array<string,mixed>> $shots
* @param int[] $durations
*/
private static function overlayDocument(
array $shots,
array $durations,
string $aspectRatio,
bool $showSubtitles,
string $screenTextLanguage
): ?string
{
$screenTextLanguage = ShortDramaPlannerService::normalizeScreenTextLanguage($screenTextLanguage);
$portrait = $aspectRatio !== '16:9';
$playResX = $portrait ? 1080 : 1920;
$playResY = $portrait ? 1920 : 1080;
$fontSize = $portrait ? 54 : 48;
$marginV = $portrait ? 150 : 72;
$lineLength = $portrait ? 17 : 28;
$cursor = 0.0;
$events = [];
foreach ($shots as $index => $shot) {
$duration = (float) ($durations[$index] ?? 5);
$meta = is_array($shot['meta'] ?? null) ? $shot['meta'] : [];
if (!$meta && is_string($shot['meta'] ?? null)) {
$decoded = json_decode((string) $shot['meta'], true);
$meta = is_array($decoded) ? $decoded : [];
}
$timeline = is_array($meta['timeline'] ?? null) ? $meta['timeline'] : [];
$dialogue = $showSubtitles
? self::cleanSubtitleText((string) ($shot['dialogue'] ?? ''), $lineLength)
: '';
if ($dialogue !== '') {
$placement = (string) ($timeline['voice_timing'] ?? 'start');
$start = $cursor + 0.18;
if ($placement === 'end') {
$start = $cursor + max(0.18, $duration - 2.8);
}
$end = max($start + 0.5, $cursor + $duration - 0.16);
$events[] = 'Dialogue: 0,' . self::assTime($start) . ',' . self::assTime($end)
. ',Default,,0,0,0,,' . $dialogue;
}
$sceneText = $screenTextLanguage !== ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE
? self::cleanSceneText((string) ($timeline['screen_text'] ?? ''), $portrait ? 14 : 24)
: '';
if ($sceneText !== '') {
$start = $cursor + 0.35;
$end = max($start + 0.6, $cursor + $duration - 0.25);
$events[] = 'Dialogue: 1,' . self::assTime($start) . ',' . self::assTime($end)
. ',SceneText,,0,0,0,,' . $sceneText;
}
$cursor += $duration;
}
if (!$events) {
return null;
}
return "[Script Info]\n"
. "ScriptType: v4.00+\n"
. "PlayResX: {$playResX}\n"
. "PlayResY: {$playResY}\n"
. "WrapStyle: 0\n"
. "ScaledBorderAndShadow: yes\n\n"
. "[V4+ Styles]\n"
. "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
. "Style: Default,Noto Sans CJK SC,{$fontSize},&H00FFFFFF,&H00FFFFFF,&H50000000,&H78000000,-1,0,0,0,100,100,0,0,3,2,0,2,70,70,{$marginV},1\n"
. 'Style: SceneText,Noto Sans CJK SC,' . ($portrait ? 62 : 54) . ',&H00FFFFFF,&H00FFFFFF,&H78000000,&HA0000000,-1,0,0,0,100,100,0,0,3,3,0,8,90,90,' . ($portrait ? 260 : 105) . ",1\n\n"
. "[Events]\n"
. "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
. implode("\n", $events)
. "\n";
}
private static function cleanSubtitleText(string $text, int $lineLength): string
{
$text = trim($text);
$text = preg_replace('/^[^:\n]{1,20}[:]\s*/u', '', $text) ?? $text;
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
$text = trim($text);
if ($text === '') {
return '';
}
$text = mb_substr($text, 0, 80);
$text = str_replace(['\\', '{', '}'], ['', '', ''], $text);
$lines = [];
for ($offset = 0, $length = mb_strlen($text); $offset < $length; $offset += $lineLength) {
$lines[] = mb_substr($text, $offset, $lineLength);
}
return implode('\\N', array_slice($lines, 0, 3));
}
private static function cleanSceneText(string $text, int $lineLength): string
{
$text = preg_replace('/\s+/u', ' ', trim($text)) ?? trim($text);
$text = preg_replace('/^[\s\"\'“”‘’]+|[\s\"\'“”‘’]+$/u', '', $text) ?? trim($text);
if ($text === '') {
return '';
}
$text = mb_substr($text, 0, 100);
$text = str_replace(['\\', '{', '}'], ['', '', ''], $text);
$lines = [];
for ($offset = 0, $length = mb_strlen($text); $offset < $length; $offset += $lineLength) {
$lines[] = mb_substr($text, $offset, $lineLength);
}
return implode('\\N', array_slice($lines, 0, 3));
}
private static function assTime(float $seconds): string
{
$centiseconds = max(0, (int) round($seconds * 100));
$hours = intdiv($centiseconds, 360000);
$minutes = intdiv($centiseconds % 360000, 6000);
$secs = intdiv($centiseconds % 6000, 100);
return sprintf('%d:%02d:%02d.%02d', $hours, $minutes, $secs, $centiseconds % 100);
}
private static function escapeFilterPath(string $path): string
{
return str_replace(['\\', "'", ':'], ['\\\\', "\\'", '\\:'], $path);
}
/** @return array{int,int} */
private static function renderSize(string $aspectRatio, string $quality): array
{
$portrait = $aspectRatio !== '16:9';
return match ($quality) {
'high' => $portrait ? [768, 1344] : [1344, 768],
'fast' => $portrait ? [512, 896] : [896, 512],
default => $portrait ? [576, 1024] : [1024, 576],
};
}
/** @return array{int,string} */
private static function run(array $command): array
{
$pipes = [];
$process = @proc_open($command, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
if (!is_resource($process)) {
throw new \RuntimeException('服务器未安装或无法启动 FFmpeg');
}
fclose($pipes[0]);
stream_get_contents($pipes[1]);
fclose($pipes[1]);
$error = (string) stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($process);
return [$code, $error];
}
}
+1070
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
<?php
$targetModel = env('SHORT_DRAMA_TARGET_MODEL', 'MiniMax H3');
$directorPrompt = <<<'PROMPT'
请全面化身为顶级AI视频提示词架构师、电影级镜头语言分析师与多模态视频逆向工程师。不要只总结视频内容,请按照时间顺序逐镜头拆解主体身份与外观锚点、动作轨迹、表情变化、场景空间、前中后景关系、景别、机位、焦段、构图、景深、运镜方式、运动速度、光线方向、综合色温、材质、节奏、转场、特效和环境声音。
先输出完整镜头时间轴,再反推出一套适用于[目标视频模型]的成品生成提示词。
多镜头必须分别标注起始画面、人物动作、摄影机运动、结束画面和镜头衔接,并补充防止人物变脸、服装漂移、肢体错误、背景闪烁、动作断裂和物理关系失真的禁止项。
只学习镜头逻辑和视觉方法,不照抄人物、品牌、台词和具体故事。
PROMPT;
return [
'target_model' => $targetModel,
'director_system_prompt' => str_replace('[目标视频模型]', $targetModel, $directorPrompt),
'tts_base_url' => rtrim((string) env('SHORT_DRAMA_TTS_BASE_URL', 'http://192.168.110.111:50000'), '/'),
'tts_prompt_wav' => (string) env(
'SHORT_DRAMA_TTS_PROMPT_WAV',
'/usr/local/share/cosyvoice/zero_shot_prompt.wav'
),
'tts_prompt_text' => (string) env(
'SHORT_DRAMA_TTS_PROMPT_TEXT',
'You are a helpful assistant.<|endofprompt|>希望你以后能够做的比我还好呦。'
),
];
@@ -0,0 +1,83 @@
<?php
/**
* 访客管理权限迁移(可重复执行)。
* 用法: php database/migrate_guest_management.php
*/
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use app\service\PermissionCatalog;
use think\facade\Db;
function guestManagementTableExists(string $table): bool
{
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
[$db, $table]
);
return !empty($rows);
}
try {
if (guestManagementTableExists('users')) {
Db::execute("UPDATE users SET last_login_at = created_at WHERE LEFT(username, 6) = 'guest_' AND last_login_at IS NULL");
}
if (guestManagementTableExists('sys_permissions')) {
$parent = Db::name('sys_permissions')->where('code', 'dir:org')->find();
if ($parent) {
$menu = Db::name('sys_permissions')->where('code', 'menu:guests')->find();
if (!$menu) {
$menuId = Db::name('sys_permissions')->insertGetId([
'type' => 'menu',
'code' => 'menu:guests',
'name' => '访客管理',
'parent_id' => (int) $parent['id'],
'path' => '/guests',
'icon' => 'visitors',
'sort_order' => 15,
'is_system' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$menu = ['id' => $menuId];
echo "Added menu:guests\n";
}
foreach ([
['btn:guest:status', '启用/禁用访客'],
['btn:guest:delete', '删除访客'],
] as [$code, $name]) {
if (!Db::name('sys_permissions')->where('code', $code)->find()) {
Db::name('sys_permissions')->insert([
'type' => 'btn',
'code' => $code,
'name' => $name,
'parent_id' => (int) $menu['id'],
'sort_order' => 0,
'is_system' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
echo "Added {$code}\n";
}
}
}
if (guestManagementTableExists('roles')) {
Db::name('roles')->where('slug', 'super_admin')->update([
'permissions' => json_encode(PermissionCatalog::fullPermissions(), JSON_UNESCAPED_UNICODE),
]);
}
}
echo "Guest management migration completed.\n";
} catch (\Throwable $e) {
echo 'Error: ' . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,98 @@
<?php
/**
* 邀请码与对应后台权限迁移(可重复执行)。
* 用法: php database/migrate_invitation_codes.php
*/
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use app\service\PermissionCatalog;
use think\facade\Db;
function invitationTableExists(string $table): bool
{
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
[$db, $table]
);
return !empty($rows);
}
try {
if (!invitationTableExists('invitation_codes')) {
Db::execute("CREATE TABLE invitation_codes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL UNIQUE,
department_id INT UNSIGNED NULL,
created_by INT UNSIGNED NULL,
used_by INT UNSIGNED NULL,
status ENUM('active','used','revoked') NOT NULL DEFAULT 'active',
expires_at TIMESTAMP NULL,
used_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_invitation_status (status),
INDEX idx_invitation_department (department_id),
INDEX idx_invitation_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo "Created table invitation_codes\n";
}
if (invitationTableExists('sys_permissions')) {
$parent = Db::name('sys_permissions')->where('code', 'dir:org')->find();
if ($parent) {
$menu = Db::name('sys_permissions')->where('code', 'menu:invitations')->find();
if (!$menu) {
$menuId = Db::name('sys_permissions')->insertGetId([
'type' => 'menu',
'code' => 'menu:invitations',
'name' => '邀请码管理',
'parent_id' => (int) $parent['id'],
'path' => '/invitations',
'icon' => 'ticket',
'sort_order' => 25,
'is_system' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$menu = ['id' => $menuId];
echo "Added menu:invitations\n";
}
foreach ([
['btn:invitation:create', '生成邀请码'],
['btn:invitation:revoke', '作废邀请码'],
] as [$code, $name]) {
if (!Db::name('sys_permissions')->where('code', $code)->find()) {
Db::name('sys_permissions')->insert([
'type' => 'btn',
'code' => $code,
'name' => $name,
'parent_id' => (int) $menu['id'],
'sort_order' => 0,
'is_system' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
echo "Added {$code}\n";
}
}
}
if (invitationTableExists('roles')) {
Db::name('roles')->where('slug', 'super_admin')->update([
'permissions' => json_encode(PermissionCatalog::fullPermissions(), JSON_UNESCAPED_UNICODE),
]);
}
}
echo "Invitation migration completed.\n";
} catch (\Throwable $e) {
echo 'Error: ' . $e->getMessage() . "\n";
exit(1);
}
+237
View File
@@ -0,0 +1,237 @@
<?php
/**
* MiniMax H3 短剧工坊迁移(可重复执行)。
* 用法: php database/migrate_short_drama.php
*/
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use app\service\SettingsService;
use app\service\ShortDramaPlannerService;
use think\facade\Db;
function shortDramaTableExists(string $table): bool
{
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1',
[$db, $table]
);
return !empty($rows);
}
function shortDramaColumnExists(string $table, string $column): bool
{
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1',
[$db, $table, $column]
);
return !empty($rows);
}
function shortDramaColumnType(string $table, string $column): string
{
$db = Db::getConfig('database') ?: env('DB_NAME', 'ai_chat');
$rows = Db::query(
'SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1',
[$db, $table, $column]
);
return strtolower((string) ($rows[0]['DATA_TYPE'] ?? ''));
}
try {
if (!shortDramaTableExists('video_projects')) {
Db::execute("CREATE TABLE video_projects (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
title VARCHAR(160) NOT NULL,
idea TEXT NOT NULL,
style VARCHAR(100) DEFAULT '电影写实',
aspect_ratio VARCHAR(10) NOT NULL DEFAULT '9:16',
episode_duration INT UNSIGNED NOT NULL DEFAULT 30,
duration_mode VARCHAR(10) NOT NULL DEFAULT 'fixed',
quality VARCHAR(20) NOT NULL DEFAULT 'fast',
voice_language VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
show_subtitles TINYINT(1) NOT NULL DEFAULT 1,
character_origin VARCHAR(20) NOT NULL DEFAULT 'east_asian',
screen_text_language VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
shot_duration_mode VARCHAR(10) NOT NULL DEFAULT 'auto',
status VARCHAR(30) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_video_project_user (user_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo "Created table video_projects\n";
}
if (!shortDramaColumnExists('video_projects', 'voice_language')) {
// 历史成片保留原音;尚未完成的项目升级为普通话重配音。
Db::execute("ALTER TABLE video_projects ADD COLUMN voice_language VARCHAR(20) NOT NULL DEFAULT 'native' AFTER quality");
Db::execute("UPDATE video_projects SET voice_language = 'zh-CN'
WHERE status IN ('storyboard', 'generating', 'needs_attention')
OR id = (SELECT id FROM (SELECT id FROM video_projects ORDER BY id DESC LIMIT 1) recent_project)");
Db::execute("ALTER TABLE video_projects ALTER COLUMN voice_language SET DEFAULT 'zh-CN'");
echo "Added video_projects.voice_language\n";
}
if (!shortDramaColumnExists('video_projects', 'duration_mode')) {
Db::execute("ALTER TABLE video_projects ADD COLUMN duration_mode VARCHAR(10) NOT NULL DEFAULT 'fixed' AFTER episode_duration");
echo "Added video_projects.duration_mode\n";
}
if (!shortDramaColumnExists('video_projects', 'show_subtitles')) {
// 已有成片没有烧录字幕,保持关闭;新建项目默认显示字幕。
Db::execute('ALTER TABLE video_projects ADD COLUMN show_subtitles TINYINT(1) NOT NULL DEFAULT 0 AFTER voice_language');
Db::execute('ALTER TABLE video_projects ALTER COLUMN show_subtitles SET DEFAULT 1');
echo "Added video_projects.show_subtitles\n";
}
if (!shortDramaColumnExists('video_projects', 'character_origin')) {
Db::execute("ALTER TABLE video_projects ADD COLUMN character_origin VARCHAR(20) NOT NULL DEFAULT 'east_asian' AFTER show_subtitles");
echo "Added video_projects.character_origin\n";
}
if (!shortDramaColumnExists('video_projects', 'screen_text_language')) {
// 历史项目没有精确场景文字层,保持关闭;新项目默认使用简体中文。
Db::execute("ALTER TABLE video_projects ADD COLUMN screen_text_language VARCHAR(20) NOT NULL DEFAULT 'none' AFTER character_origin");
Db::execute("ALTER TABLE video_projects ALTER COLUMN screen_text_language SET DEFAULT 'zh-CN'");
echo "Added video_projects.screen_text_language\n";
}
if (!shortDramaColumnExists('video_projects', 'shot_duration_mode')) {
// 历史项目已按 5 秒分镜生成;新项目默认交给 AI 在 5/10 秒间选择。
Db::execute("ALTER TABLE video_projects ADD COLUMN shot_duration_mode VARCHAR(10) NOT NULL DEFAULT '5' AFTER screen_text_language");
Db::execute("ALTER TABLE video_projects ALTER COLUMN shot_duration_mode SET DEFAULT 'auto'");
echo "Added video_projects.shot_duration_mode\n";
}
if (shortDramaColumnType('video_projects', 'episode_duration') !== 'int') {
Db::execute('ALTER TABLE video_projects MODIFY COLUMN episode_duration INT UNSIGNED NOT NULL DEFAULT 30');
echo "Expanded video_projects.episode_duration to INT UNSIGNED\n";
}
if (shortDramaColumnType('video_projects', 'idea') !== 'mediumtext') {
Db::execute('ALTER TABLE video_projects MODIFY COLUMN idea MEDIUMTEXT NOT NULL');
echo "Expanded video_projects.idea to MEDIUMTEXT\n";
}
if (!shortDramaTableExists('video_characters')) {
Db::execute("CREATE TABLE video_characters (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
name VARCHAR(80) NOT NULL,
description TEXT NULL,
reference_upload_id INT UNSIGNED NULL,
voice_key VARCHAR(100) NULL,
is_locked TINYINT(1) NOT NULL DEFAULT 1,
asset_version INT UNSIGNED NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES video_projects(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_video_character_project (project_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo "Created table video_characters\n";
}
if (!shortDramaTableExists('video_episodes')) {
Db::execute("CREATE TABLE video_episodes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
episode_no INT UNSIGNED NOT NULL DEFAULT 1,
title VARCHAR(160) NOT NULL,
script MEDIUMTEXT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'storyboard',
progress TINYINT UNSIGNED NOT NULL DEFAULT 0,
progress_message VARCHAR(255) NULL,
final_upload_id INT UNSIGNED NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES video_projects(id) ON DELETE CASCADE,
UNIQUE KEY uk_video_episode_number (project_id, episode_no),
INDEX idx_video_episode_status (status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo "Created table video_episodes\n";
}
if (!shortDramaTableExists('video_shots')) {
Db::execute("CREATE TABLE video_shots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
episode_id BIGINT UNSIGNED NOT NULL,
shot_no INT UNSIGNED NOT NULL,
title VARCHAR(160) NOT NULL,
prompt TEXT NOT NULL,
dialogue TEXT NULL,
duration_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 5,
workflow_type VARCHAR(20) NOT NULL DEFAULT 'fl2va',
status VARCHAR(30) NOT NULL DEFAULT 'draft',
prompt_id VARCHAR(80) NULL,
seed BIGINT UNSIGNED NULL,
output_upload_id INT UNSIGNED NULL,
error_message TEXT NULL,
meta JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (episode_id) REFERENCES video_episodes(id) ON DELETE CASCADE,
UNIQUE KEY uk_video_shot_number (episode_id, shot_no),
INDEX idx_video_shot_prompt (prompt_id),
INDEX idx_video_shot_status (status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo "Created table video_shots\n";
}
// 旧版项目没有保存对白。为升级成普通话的项目补齐每个镜头的短句,避免保留 H3 含糊原声。
$dialogueBackfillCount = 0;
$mandarinProjects = Db::query("SELECT * FROM video_projects WHERE voice_language = 'zh-CN'");
foreach ($mandarinProjects as $mandarinProject) {
$plan = ShortDramaPlannerService::plan(
(string) $mandarinProject['idea'],
(int) $mandarinProject['episode_duration'],
(string) $mandarinProject['aspect_ratio'],
(string) $mandarinProject['style'],
'zh-CN',
(string) ($mandarinProject['character_origin'] ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN),
(string) ($mandarinProject['screen_text_language'] ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE),
(string) ($mandarinProject['shot_duration_mode'] ?? ShortDramaPlannerService::SHOT_DURATION_FIVE)
);
$projectShots = Db::query(
'SELECT s.id, s.shot_no, s.dialogue FROM video_shots s '
. 'INNER JOIN video_episodes e ON e.id = s.episode_id '
. 'WHERE e.project_id = ? ORDER BY s.shot_no',
[(int) $mandarinProject['id']]
);
foreach ($projectShots as $projectShot) {
if (trim((string) ($projectShot['dialogue'] ?? '')) !== '') {
continue;
}
$plannedShot = $plan['shots'][max(0, (int) $projectShot['shot_no'] - 1)] ?? null;
if (!is_array($plannedShot) || trim((string) ($plannedShot['dialogue'] ?? '')) === '') {
continue;
}
Db::execute(
'UPDATE video_shots SET dialogue = ? WHERE id = ?',
[(string) $plannedShot['dialogue'], (int) $projectShot['id']]
);
$dialogueBackfillCount++;
}
}
if ($dialogueBackfillCount > 0) {
echo "Backfilled {$dialogueBackfillCount} Mandarin shot dialogues\n";
}
$features = SettingsService::getFeatures();
$features['short_drama'] = $features['short_drama'] ?? true;
SettingsService::set('features', $features);
echo "Short drama migration completed.\n";
} catch (\Throwable $e) {
echo 'Error: ' . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,52 @@
<?php
/**
* Docker 内短剧接力进程。
*
* H3 的下一镜头必须等上一镜头落盘并提取尾帧后才能提交,因此由这个进程
* 持续触发同一套状态接口。页面关闭后,长视频仍会按顺序继续生成。
*/
require __DIR__ . '/../vendor/autoload.php';
$app = new think\App();
$app->initialize();
use app\model\VideoProject;
use app\service\JwtService;
set_time_limit(0);
$internalBaseUrl = rtrim((string) (getenv('SHORT_DRAMA_INTERNAL_URL') ?: 'http://web'), '/');
if (!preg_match('#^https?://#i', $internalBaseUrl)) {
fwrite(STDERR, "[short-drama-worker] SHORT_DRAMA_INTERNAL_URL 必须使用 http 或 https\n");
exit(1);
}
while (true) {
try {
$projects = VideoProject::where('status', 'generating')
->order('updated_at')
->limit(30)
->select();
foreach ($projects as $project) {
$token = JwtService::generateToken(['user_id' => (int) $project->user_id]);
$url = $internalBaseUrl . '/api/short-drama/projects/' . (int) $project->id . '/status';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_HTTPGET => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 25,
CURLOPT_CONNECTTIMEOUT => 3,
]);
curl_exec($ch);
curl_close($ch);
}
} catch (Throwable $error) {
fwrite(STDERR, '[short-drama-worker] ' . $error->getMessage() . "\n");
}
sleep(8);
}
+99 -1
View File
@@ -1,4 +1,5 @@
-- AI Chat Database Schema
SET NAMES utf8mb4;
CREATE DATABASE IF NOT EXISTS ai_chat DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE ai_chat;
@@ -58,6 +59,23 @@ CREATE TABLE IF NOT EXISTS users (
FOREIGN KEY (membership_level_id) REFERENCES membership_levels(id)
) ENGINE=InnoDB;
-- 一次性注册邀请码;指定 department_id 后,新用户会自动归属到该部门
CREATE TABLE IF NOT EXISTS invitation_codes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL UNIQUE,
department_id INT UNSIGNED NULL,
created_by INT UNSIGNED NULL,
used_by INT UNSIGNED NULL,
status ENUM('active', 'used', 'revoked') NOT NULL DEFAULT 'active',
expires_at TIMESTAMP NULL,
used_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_invitation_status (status),
INDEX idx_invitation_department (department_id),
INDEX idx_invitation_created (created_at)
) ENGINE=InnoDB;
-- 系统功能开关
CREATE TABLE IF NOT EXISTS system_settings (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
@@ -134,6 +152,86 @@ CREATE TABLE IF NOT EXISTS uploads (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
-- 短剧项目(一个项目可包含多集,角色资产在项目内复用)
CREATE TABLE IF NOT EXISTS video_projects (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
title VARCHAR(160) NOT NULL,
idea MEDIUMTEXT NOT NULL,
style VARCHAR(100) DEFAULT '电影写实',
aspect_ratio VARCHAR(10) NOT NULL DEFAULT '9:16',
episode_duration INT UNSIGNED NOT NULL DEFAULT 30,
duration_mode VARCHAR(10) NOT NULL DEFAULT 'fixed',
quality VARCHAR(20) NOT NULL DEFAULT 'fast',
voice_language VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
show_subtitles TINYINT(1) NOT NULL DEFAULT 1,
character_origin VARCHAR(20) NOT NULL DEFAULT 'east_asian',
screen_text_language VARCHAR(20) NOT NULL DEFAULT 'zh-CN',
shot_duration_mode VARCHAR(10) NOT NULL DEFAULT 'auto',
status VARCHAR(30) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_video_project_user (user_id, updated_at)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS video_characters (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
name VARCHAR(80) NOT NULL,
description TEXT NULL,
reference_upload_id INT UNSIGNED NULL,
voice_key VARCHAR(100) NULL,
is_locked TINYINT(1) NOT NULL DEFAULT 1,
asset_version INT UNSIGNED NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES video_projects(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_video_character_project (project_id, id)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS video_episodes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT UNSIGNED NOT NULL,
episode_no INT UNSIGNED NOT NULL DEFAULT 1,
title VARCHAR(160) NOT NULL,
script MEDIUMTEXT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'storyboard',
progress TINYINT UNSIGNED NOT NULL DEFAULT 0,
progress_message VARCHAR(255) NULL,
final_upload_id INT UNSIGNED NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES video_projects(id) ON DELETE CASCADE,
UNIQUE KEY uk_video_episode_number (project_id, episode_no),
INDEX idx_video_episode_status (status, updated_at)
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS video_shots (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
episode_id BIGINT UNSIGNED NOT NULL,
shot_no INT UNSIGNED NOT NULL,
title VARCHAR(160) NOT NULL,
prompt TEXT NOT NULL,
dialogue TEXT NULL,
duration_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 5,
workflow_type VARCHAR(20) NOT NULL DEFAULT 'fl2va',
status VARCHAR(30) NOT NULL DEFAULT 'draft',
prompt_id VARCHAR(80) NULL,
seed BIGINT UNSIGNED NULL,
output_upload_id INT UNSIGNED NULL,
error_message TEXT NULL,
meta JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (episode_id) REFERENCES video_episodes(id) ON DELETE CASCADE,
UNIQUE KEY uk_video_shot_number (episode_id, shot_no),
INDEX idx_video_shot_prompt (prompt_id),
INDEX idx_video_shot_status (status, updated_at)
) ENGINE=InnoDB;
-- 每日消息统计
CREATE TABLE IF NOT EXISTS user_daily_stats (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
@@ -160,7 +258,7 @@ INSERT INTO departments (name, parent_id, sort_order) VALUES
-- 默认系统设置
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('features', '{"markdown":true,"image":true,"video":true,"voice":true,"document":true,"emoji":true,"upload_image":true,"upload_video":true,"upload_file":true,"paste_image":true}', '功能开关'),
('features', '{"markdown":true,"image":true,"video":true,"voice":true,"document":true,"emoji":true,"upload_image":true,"upload_video":true,"upload_file":true,"paste_image":true,"short_drama":true}', '功能开关'),
('site_name', 'AI Chat', '站点名称'),
('allow_register', 'true', '是否允许注册');
@@ -1 +1 @@
import{_ as i,i as d,j as r,c,b as s,a as e,t as n,r as p,o as _,U as o}from"./index-BZEF5hZc.js";const u={class:"stats-grid"},v={class:"stat-card"},m={class:"stat-icon"},g={class:"stat-value"},b={class:"stat-card"},x={class:"stat-icon"},f={class:"stat-value"},y={class:"stat-card"},z={class:"stat-icon"},V={class:"stat-value"},w={class:"stat-card"},B={class:"stat-icon"},D={class:"stat-value"},k={__name:"DashboardView",setup(I){const t=p({users:0,conversations:0,messages:0,today_messages:0});return d(async()=>{const l=await r.get("/admin/stats");t.value=l.data.data}),(l,a)=>(_(),c("div",null,[a[8]||(a[8]=s("div",{class:"page-header"},[s("h2",null,"数据概览"),s("p",null,"系统运行统计数据")],-1)),s("div",u,[s("article",v,[s("span",m,[e(o,{name:"users",size:21})]),s("span",g,n(t.value.users),1),a[0]||(a[0]=s("span",{class:"stat-label"},"用户总数",-1)),a[1]||(a[1]=s("span",{class:"stat-index"},"01",-1))]),s("article",b,[s("span",x,[e(o,{name:"conversations",size:21})]),s("span",f,n(t.value.conversations),1),a[2]||(a[2]=s("span",{class:"stat-label"},"会话总数",-1)),a[3]||(a[3]=s("span",{class:"stat-index"},"02",-1))]),s("article",y,[s("span",z,[e(o,{name:"messages",size:21})]),s("span",V,n(t.value.messages),1),a[4]||(a[4]=s("span",{class:"stat-label"},"消息总数",-1)),a[5]||(a[5]=s("span",{class:"stat-index"},"03",-1))]),s("article",w,[s("span",B,[e(o,{name:"activity",size:21})]),s("span",D,n(t.value.today_messages),1),a[6]||(a[6]=s("span",{class:"stat-label"},"今日消息",-1)),a[7]||(a[7]=s("span",{class:"stat-index"},"04",-1))])])]))}},U=i(k,[["__scopeId","data-v-5a83d2b7"]]);export{U as default};
import{_ as i,i as d,j as r,c,b as s,a as e,t as n,r as p,o as _,U as o}from"./index-9aV50nsX.js";const u={class:"stats-grid"},v={class:"stat-card"},m={class:"stat-icon"},g={class:"stat-value"},b={class:"stat-card"},x={class:"stat-icon"},f={class:"stat-value"},y={class:"stat-card"},z={class:"stat-icon"},V={class:"stat-value"},w={class:"stat-card"},B={class:"stat-icon"},D={class:"stat-value"},k={__name:"DashboardView",setup(I){const t=p({users:0,conversations:0,messages:0,today_messages:0});return d(async()=>{const l=await r.get("/admin/stats");t.value=l.data.data}),(l,a)=>(_(),c("div",null,[a[8]||(a[8]=s("div",{class:"page-header"},[s("h2",null,"数据概览"),s("p",null,"系统运行统计数据")],-1)),s("div",u,[s("article",v,[s("span",m,[e(o,{name:"users",size:21})]),s("span",g,n(t.value.users),1),a[0]||(a[0]=s("span",{class:"stat-label"},"用户总数",-1)),a[1]||(a[1]=s("span",{class:"stat-index"},"01",-1))]),s("article",b,[s("span",x,[e(o,{name:"conversations",size:21})]),s("span",f,n(t.value.conversations),1),a[2]||(a[2]=s("span",{class:"stat-label"},"会话总数",-1)),a[3]||(a[3]=s("span",{class:"stat-index"},"02",-1))]),s("article",y,[s("span",z,[e(o,{name:"messages",size:21})]),s("span",V,n(t.value.messages),1),a[4]||(a[4]=s("span",{class:"stat-label"},"消息总数",-1)),a[5]||(a[5]=s("span",{class:"stat-index"},"03",-1))]),s("article",w,[s("span",B,[e(o,{name:"activity",size:21})]),s("span",D,n(t.value.today_messages),1),a[6]||(a[6]=s("span",{class:"stat-label"},"今日消息",-1)),a[7]||(a[7]=s("span",{class:"stat-index"},"04",-1))])])]))}},U=i(k,[["__scopeId","data-v-5a83d2b7"]]);export{U as default};
@@ -0,0 +1 @@
.toolbar[data-v-adb7470c]{margin-bottom:16px}.empty[data-v-adb7470c]{text-align:center;padding:32px;color:var(--text-muted)}.danger[data-v-adb7470c]{color:var(--danger)}
@@ -1 +0,0 @@
.toolbar[data-v-b11f0247]{margin-bottom:16px}.empty[data-v-b11f0247]{text-align:center;padding:32px;color:var(--text-muted)}.danger[data-v-b11f0247]{color:var(--danger)}
@@ -1 +1 @@
import{_ as L,u as N,i as U,j as b,c as s,b as t,k as f,f as u,F as w,l as $,w as E,t as d,e as k,v as B,m as F,r as m,p as j,n as z,o,C as A}from"./index-BZEF5hZc.js";const I={class:"toolbar"},O={class:"panel"},R={class:"data-table"},T=["onClick"],q=["onClick"],G=["onClick"],H={key:0,class:"empty"},J={class:"modal"},K={class:"modal-header"},P={class:"modal-body"},Q={class:"form-group"},W={class:"form-group"},X=["value","disabled"],Y={class:"form-group"},Z={key:0,class:"form-error"},tt={__name:"DepartmentsView",setup(et){const c=N(),v=m([]),h=m([]),p=m(!1),r=m(null),i=m(""),l=j({name:"",parent_id:null,sort_order:0}),D=z(()=>v.value.filter(a=>a.id!==r.value));U(y);async function y(){const e=(await b.get("/admin/departments")).data.data||{};v.value=e.tree||[],h.value=e.list||[]}function V(a){var e;return a&&((e=h.value.find(n=>n.id===a))==null?void 0:e.name)||"-"}function g(a=null){r.value=null,l.name="",l.parent_id=a,l.sort_order=0,i.value="",p.value=!0}function M(a){r.value=a.id,l.name=a.name,l.parent_id=a.parent_id||null,l.sort_order=a.sort_order??0,i.value="",p.value=!0}function _(){p.value=!1}async function x(){if(i.value="",!l.name.trim()){i.value="请填写部门名称";return}const a={name:l.name.trim(),parent_id:l.parent_id,sort_order:l.sort_order};try{r.value?await b.put(`/admin/departments/${r.value}`,a):await b.post("/admin/departments",a),_(),await y()}catch(e){i.value=e.message||"保存失败"}}async function S(a){if(confirm(`确定删除部门「${a.name}」?`))try{await b.delete(`/admin/departments/${a.id}`),await y()}catch(e){alert(e.message||"删除失败")}}return(a,e)=>(o(),s("div",null,[e[9]||(e[9]=t("div",{class:"page-header"},[t("h2",null,"部门管理"),t("p",null,"维护组织部门层级,上级部门可查看下级部门员工聊天记录")],-1)),t("div",I,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-primary",onClick:e[0]||(e[0]=n=>g())},"新增部门")):u("",!0)]),t("div",O,[t("table",R,[e[4]||(e[4]=t("thead",null,[t("tr",null,[t("th",null,"部门名称"),t("th",null,"上级部门"),t("th",null,"排序"),t("th",null,"操作")])],-1)),t("tbody",null,[(o(!0),s(w,null,$(v.value,n=>(o(),s("tr",{key:n.id},[t("td",null,[t("span",{style:A({paddingLeft:`${n.depth*16}px`})},d(n.label||n.name),5)]),t("td",null,d(V(n.parent_id)),1),t("td",null,d(n.sort_order??0),1),t("td",null,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-ghost",onClick:C=>g(n.id)},"添加下级",8,T)):u("",!0),f(c).hasButton("btn:dept:edit")?(o(),s("button",{key:1,class:"btn btn-ghost",onClick:C=>M(n)},"编辑",8,q)):u("",!0),f(c).hasButton("btn:dept:delete")?(o(),s("button",{key:2,class:"btn btn-ghost danger",onClick:C=>S(n)},"删除",8,G)):u("",!0)])]))),128))])]),v.value.length?u("",!0):(o(),s("p",H,"暂无部门"))]),p.value?(o(),s("div",{key:0,class:"modal-overlay",onClick:E(_,["self"])},[t("div",J,[t("div",K,[t("h3",null,d(r.value?"编辑部门":"新增部门"),1),t("button",{onClick:_},"×")]),t("div",P,[t("div",Q,[e[5]||(e[5]=t("label",null,"部门名称",-1)),k(t("input",{"onUpdate:modelValue":e[1]||(e[1]=n=>l.name=n),class:"form-input"},null,512),[[B,l.name]])]),t("div",W,[e[7]||(e[7]=t("label",null,"上级部门",-1)),k(t("select",{"onUpdate:modelValue":e[2]||(e[2]=n=>l.parent_id=n),class:"form-select"},[e[6]||(e[6]=t("option",{value:null},"无(顶级部门)",-1)),(o(!0),s(w,null,$(D.value,n=>(o(),s("option",{key:n.id,value:n.id,disabled:r.value===n.id},d(n.label||n.name),9,X))),128))],512),[[F,l.parent_id]])]),t("div",Y,[e[8]||(e[8]=t("label",null,"排序",-1)),k(t("input",{"onUpdate:modelValue":e[3]||(e[3]=n=>l.sort_order=n),type:"number",class:"form-input"},null,512),[[B,l.sort_order,void 0,{number:!0}]])]),i.value?(o(),s("p",Z,d(i.value),1)):u("",!0)]),t("div",{class:"modal-footer"},[t("button",{class:"btn btn-ghost",onClick:_},"取消"),t("button",{class:"btn btn-primary",onClick:x},"保存")])])])):u("",!0)]))}},at=L(tt,[["__scopeId","data-v-b11f0247"]]);export{at as default};
import{_ as L,u as N,i as U,j as b,c as s,b as t,k as f,f as u,F as w,l as $,w as E,t as d,e as k,v as B,m as F,r as m,p as j,n as z,o,C as A}from"./index-9aV50nsX.js";const I={class:"toolbar"},O={class:"panel"},R={class:"data-table"},T=["onClick"],q=["onClick"],G=["onClick"],H={key:0,class:"empty"},J={class:"modal"},K={class:"modal-header"},P={class:"modal-body"},Q={class:"form-group"},W={class:"form-group"},X=["value","disabled"],Y={class:"form-group"},Z={key:0,class:"form-error"},tt={__name:"DepartmentsView",setup(et){const c=N(),v=m([]),h=m([]),p=m(!1),r=m(null),i=m(""),l=j({name:"",parent_id:null,sort_order:0}),D=z(()=>v.value.filter(a=>a.id!==r.value));U(y);async function y(){const e=(await b.get("/admin/departments")).data.data||{};v.value=e.tree||[],h.value=e.list||[]}function V(a){var e;return a&&((e=h.value.find(n=>n.id===a))==null?void 0:e.name)||"-"}function g(a=null){r.value=null,l.name="",l.parent_id=a,l.sort_order=0,i.value="",p.value=!0}function M(a){r.value=a.id,l.name=a.name,l.parent_id=a.parent_id||null,l.sort_order=a.sort_order??0,i.value="",p.value=!0}function _(){p.value=!1}async function x(){if(i.value="",!l.name.trim()){i.value="请填写部门名称";return}const a={name:l.name.trim(),parent_id:l.parent_id,sort_order:l.sort_order};try{r.value?await b.put(`/admin/departments/${r.value}`,a):await b.post("/admin/departments",a),_(),await y()}catch(e){i.value=e.message||"保存失败"}}async function S(a){if(confirm(`确定删除部门「${a.name}」?`))try{await b.delete(`/admin/departments/${a.id}`),await y()}catch(e){alert(e.message||"删除失败")}}return(a,e)=>(o(),s("div",null,[e[9]||(e[9]=t("div",{class:"page-header"},[t("h2",null,"部门管理"),t("p",null,"维护组织部门层级,上级部门可查看下级部门员工聊天记录")],-1)),t("div",I,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-primary",onClick:e[0]||(e[0]=n=>g())},"新增部门")):u("",!0)]),t("div",O,[t("table",R,[e[4]||(e[4]=t("thead",null,[t("tr",null,[t("th",null,"部门名称"),t("th",null,"上级部门"),t("th",null,"排序"),t("th",null,"操作")])],-1)),t("tbody",null,[(o(!0),s(w,null,$(v.value,n=>(o(),s("tr",{key:n.id},[t("td",null,[t("span",{style:A({paddingLeft:`${n.depth*16}px`})},d(n.label||n.name),5)]),t("td",null,d(V(n.parent_id)),1),t("td",null,d(n.sort_order??0),1),t("td",null,[f(c).hasButton("btn:dept:create")?(o(),s("button",{key:0,class:"btn btn-ghost",onClick:C=>g(n.id)},"添加下级",8,T)):u("",!0),f(c).hasButton("btn:dept:edit")?(o(),s("button",{key:1,class:"btn btn-ghost",onClick:C=>M(n)},"编辑",8,q)):u("",!0),f(c).hasButton("btn:dept:delete")?(o(),s("button",{key:2,class:"btn btn-ghost danger",onClick:C=>S(n)},"删除",8,G)):u("",!0)])]))),128))])]),v.value.length?u("",!0):(o(),s("p",H,"暂无部门"))]),p.value?(o(),s("div",{key:0,class:"modal-overlay",onClick:E(_,["self"])},[t("div",J,[t("div",K,[t("h3",null,d(r.value?"编辑部门":"新增部门"),1),t("button",{onClick:_},"×")]),t("div",P,[t("div",Q,[e[5]||(e[5]=t("label",null,"部门名称",-1)),k(t("input",{"onUpdate:modelValue":e[1]||(e[1]=n=>l.name=n),class:"form-input"},null,512),[[B,l.name]])]),t("div",W,[e[7]||(e[7]=t("label",null,"上级部门",-1)),k(t("select",{"onUpdate:modelValue":e[2]||(e[2]=n=>l.parent_id=n),class:"form-select"},[e[6]||(e[6]=t("option",{value:null},"无(顶级部门)",-1)),(o(!0),s(w,null,$(D.value,n=>(o(),s("option",{key:n.id,value:n.id,disabled:r.value===n.id},d(n.label||n.name),9,X))),128))],512),[[F,l.parent_id]])]),t("div",Y,[e[8]||(e[8]=t("label",null,"排序",-1)),k(t("input",{"onUpdate:modelValue":e[3]||(e[3]=n=>l.sort_order=n),type:"number",class:"form-input"},null,512),[[B,l.sort_order,void 0,{number:!0}]])]),i.value?(o(),s("p",Z,d(i.value),1)):u("",!0)]),t("div",{class:"modal-footer"},[t("button",{class:"btn btn-ghost",onClick:_},"取消"),t("button",{class:"btn btn-primary",onClick:x},"保存")])])])):u("",!0)]))}},at=L(tt,[["__scopeId","data-v-adb7470c"]]);export{at as default};
@@ -0,0 +1 @@
.guest-summary[data-v-257286bc]{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-bottom:16px}.summary-card[data-v-257286bc]{display:grid;grid-template-columns:1fr auto;gap:4px 16px;padding:17px 18px;border:1px solid var(--border);border-radius:14px;background:var(--glass-panel)}.summary-card span[data-v-257286bc],.summary-card small[data-v-257286bc]{color:var(--text-muted);font-size:11px}.summary-card strong[data-v-257286bc]{grid-row:span 2;font-size:27px;letter-spacing:-.04em}.guest-toolbar[data-v-257286bc],.filter-group[data-v-257286bc],.search-form[data-v-257286bc],.actions-cell[data-v-257286bc]{display:flex;align-items:center;gap:7px}.guest-toolbar[data-v-257286bc]{justify-content:space-between;margin-bottom:16px}.filter-btn.active[data-v-257286bc]{border-color:var(--accent-line);background:var(--accent-soft);color:var(--accent)}.search-form .form-input[data-v-257286bc]{width:min(260px,42vw)}.table-scroll[data-v-257286bc]{overflow-x:auto}.guest-identity[data-v-257286bc]{display:flex;min-width:220px;flex-direction:column;gap:5px}.guest-identity strong[data-v-257286bc]{font-size:13px}.guest-identity code[data-v-257286bc]{width:fit-content;padding:3px 6px;color:var(--text-muted);font-size:10px}.empty[data-v-257286bc]{padding:34px;color:var(--text-muted);text-align:center}.pagination[data-v-257286bc]{display:flex;align-items:center;justify-content:center;gap:16px;padding:18px;color:var(--text-secondary);font-size:13px}.danger[data-v-257286bc]{color:var(--danger)}@media(max-width:760px){.guest-summary[data-v-257286bc]{grid-template-columns:1fr}.guest-toolbar[data-v-257286bc]{align-items:stretch;flex-direction:column}.search-form .form-input[data-v-257286bc]{width:100%}}
@@ -0,0 +1 @@
import{_ as L,u as P,i as T,j as f,c as o,b as t,t as l,F as S,l as x,w as U,e as j,v as q,f as b,r as d,n as h,o as u,q as G,k as M}from"./index-9aV50nsX.js";const A={class:"guest-summary"},E={class:"summary-card"},O={class:"summary-card"},H={class:"summary-card"},J={class:"toolbar guest-toolbar"},K={class:"filter-group"},Q=["onClick"],R={class:"panel"},W={class:"table-scroll"},X={class:"data-table"},Y={class:"guest-identity"},Z={class:"actions-cell"},tt=["onClick"],st=["onClick"],et={key:0,class:"empty"},at={key:1,class:"empty"},lt={key:2,class:"pagination"},nt=["disabled"],ot=["disabled"],y=20,ut={__name:"GuestsView",setup(it){const k=P(),i=d([]),m=d(!1),n=d(1),c=d(0),p=d(""),g=d(""),B=[{label:"全部",value:""},{label:"正常",value:"active"},{label:"已禁用",value:"disabled"}],C=h(()=>Math.max(1,Math.ceil(c.value/y))),D=h(()=>i.value.reduce((a,s)=>a+Number(s.conversation_count||0),0)),N=h(()=>i.value.reduce((a,s)=>a+Number(s.message_count||0),0));T(r);async function r(){m.value=!0;try{const s=(await f.get("/admin/guests",{params:{page:n.value,limit:y,status:p.value||void 0,keyword:g.value||void 0}})).data.data||{};i.value=s.list||[],c.value=s.total??i.value.length}finally{m.value=!1}}function V(a){p.value=a,n.value=1,r()}function I(){n.value=1,r()}async function w(a){n.value=a,await r()}async function z(a){const s=a.status==="active"?"disabled":"active",e=s==="active"?"启用":"禁用";if(confirm(`确定${e}访客 #${_(a.username)}`))try{await f.put(`/admin/guests/${a.id}/status`,{status:s}),await r()}catch(v){alert(v.message||`${e}失败`)}}async function F(a){if(confirm(`确定删除访客 #${_(a.username)}?其会话和消息记录也会一并删除,此操作不可恢复。`))try{await f.delete(`/admin/guests/${a.id}`),i.value.length===1&&n.value>1&&(n.value-=1),await r()}catch(s){alert(s.message||"删除失败")}}function _(a){return String(a||"").replace(/^guest_/,"").slice(-8).toUpperCase()}function $(a){return a?new Date(String(a).replace(" ","T")).toLocaleString("zh-CN",{hour12:!1}):"-"}return(a,s)=>(u(),o("div",null,[s[11]||(s[11]=t("div",{class:"page-header"},[t("h2",null,"访客管理"),t("p",null,"单独记录匿名访问者的使用情况,不计入注册用户管理列表")],-1)),t("div",A,[t("div",E,[s[3]||(s[3]=t("span",null,"当前记录",-1)),t("strong",null,l(c.value),1),s[4]||(s[4]=t("small",null,"匿名访客",-1))]),t("div",O,[s[5]||(s[5]=t("span",null,"本页会话",-1)),t("strong",null,l(D.value),1),s[6]||(s[6]=t("small",null,"未删除会话",-1))]),t("div",H,[s[7]||(s[7]=t("span",null,"本页消息",-1)),t("strong",null,l(N.value),1),s[8]||(s[8]=t("small",null,"累计消息",-1))])]),t("div",J,[t("div",K,[(u(),o(S,null,x(B,e=>t("button",{key:e.value,class:G(["btn btn-ghost filter-btn",{active:p.value===e.value}]),type:"button",onClick:v=>V(e.value)},l(e.label),11,Q)),64))]),t("form",{class:"search-form",onSubmit:U(I,["prevent"])},[j(t("input",{"onUpdate:modelValue":s[0]||(s[0]=e=>g.value=e),class:"form-input",placeholder:"搜索访客编号"},null,512),[[q,g.value,void 0,{trim:!0}]]),s[9]||(s[9]=t("button",{class:"btn btn-ghost",type:"submit"},"搜索",-1))],32)]),t("div",R,[t("div",W,[t("table",X,[s[10]||(s[10]=t("thead",null,[t("tr",null,[t("th",null,"ID"),t("th",null,"访客编号"),t("th",null,"状态"),t("th",null,"会话数"),t("th",null,"消息数"),t("th",null,"首次访问"),t("th",null,"最近访问"),t("th",null,"操作")])],-1)),t("tbody",null,[(u(!0),o(S,null,x(i.value,e=>(u(),o("tr",{key:e.id},[t("td",null,l(e.id),1),t("td",null,[t("div",Y,[t("strong",null,"访客 #"+l(_(e.username)),1),t("code",null,l(e.username),1)])]),t("td",null,[t("span",{class:G(["badge",e.status==="active"?"badge-success":"badge-danger"])},l(e.status==="active"?"正常":"已禁用"),3)]),t("td",null,l(e.conversation_count||0),1),t("td",null,l(e.message_count||0),1),t("td",null,l($(e.created_at)),1),t("td",null,l($(e.last_login_at)),1),t("td",Z,[M(k).hasButton("btn:guest:status")?(u(),o("button",{key:0,class:"btn btn-ghost",type:"button",onClick:v=>z(e)},l(e.status==="active"?"禁用":"启用"),9,tt)):b("",!0),M(k).hasButton("btn:guest:delete")?(u(),o("button",{key:1,class:"btn btn-ghost danger",type:"button",onClick:v=>F(e)},"删除",8,st)):b("",!0)])]))),128))])])]),m.value?(u(),o("p",et,"正在加载...")):i.value.length?b("",!0):(u(),o("p",at,"暂无访客记录")),c.value>y?(u(),o("div",lt,[t("button",{class:"btn btn-ghost",disabled:n.value<=1,onClick:s[1]||(s[1]=e=>w(n.value-1))},"上一页",8,nt),t("span",null,l(n.value)+" / "+l(C.value),1),t("button",{class:"btn btn-ghost",disabled:n.value>=C.value,onClick:s[2]||(s[2]=e=>w(n.value+1))},"下一页",8,ot)])):b("",!0)])]))}},dt=L(ut,[["__scopeId","data-v-257286bc"]]);export{dt as default};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.invitation-toolbar[data-v-f3c9dbd2]{display:flex;align-items:center;justify-content:space-between;gap:14px}.filter-group[data-v-f3c9dbd2]{display:flex;flex-wrap:wrap;gap:6px}.filter-btn.active[data-v-f3c9dbd2]{border-color:var(--accent-line);background:var(--accent-soft);color:var(--accent)}.invitation-panel[data-v-f3c9dbd2],.table-scroll[data-v-f3c9dbd2]{overflow-x:auto}.invite-code[data-v-f3c9dbd2]{display:inline-block;padding:6px 8px;white-space:nowrap;letter-spacing:.04em}.creator-cell[data-v-f3c9dbd2]{display:flex;flex-direction:column;gap:3px}.creator-cell small[data-v-f3c9dbd2],.form-help[data-v-f3c9dbd2]{color:var(--text-muted);font-size:11px}.form-help[data-v-f3c9dbd2]{margin:7px 0 0;line-height:1.5}.empty[data-v-f3c9dbd2]{padding:34px;color:var(--text-muted);text-align:center}.danger[data-v-f3c9dbd2]{color:var(--danger)}@media(max-width:680px){.invitation-toolbar[data-v-f3c9dbd2]{align-items:stretch;flex-direction:column}}
@@ -1 +1 @@
import{_ as y,u as w,c as u,a as d,b as s,d as p,w as V,e as v,v as g,t as c,f as T,r as t,g as N,h as x,o as m,T as A,U}from"./index-BZEF5hZc.js";const k={class:"login-page"},C={class:"login-shell"},I={class:"login-aside"},L={class:"login-mark"},R={class:"login-card"},S={class:"form-group"},q={class:"form-group"},B={key:0,class:"form-error"},D=["disabled"],M={__name:"LoginView",setup(E){const f=N(),b=x(),_=w(),a=t(""),n=t(""),o=t(""),l=t(!1);async function h(){o.value="",l.value=!0;try{await _.login(a.value,n.value),f.push(b.query.redirect||"/dashboard")}catch(r){o.value=r.message}finally{l.value=!1}}return(r,e)=>(m(),u("div",k,[d(A,{class:"login-theme-toggle"}),s("div",C,[s("div",I,[s("span",L,[d(U,{name:"bolt",size:22})]),e[2]||(e[2]=s("span",{class:"login-eyebrow"},"AI CHAT / ADMIN",-1)),e[3]||(e[3]=s("h1",null,[p("让系统配置"),s("br"),p("保持清晰可控")],-1)),e[4]||(e[4]=s("p",null,"统一管理用户、模型、权限与会话数据。",-1)),e[5]||(e[5]=s("span",{class:"login-version"},"CONTROL SURFACE · V2",-1))]),s("div",R,[e[8]||(e[8]=s("div",{class:"login-header"},[s("span",{class:"status-dot","aria-hidden":"true"}),s("span",null,"安全入口"),s("h2",null,"登录管理后台"),s("p",null,"请使用管理员账户继续")],-1)),s("form",{onSubmit:V(h,["prevent"])},[s("div",S,[e[6]||(e[6]=s("label",null,"账号",-1)),v(s("input",{"onUpdate:modelValue":e[0]||(e[0]=i=>a.value=i),class:"form-input",placeholder:"管理员用户名或邮箱",required:""},null,512),[[g,a.value]])]),s("div",q,[e[7]||(e[7]=s("label",null,"密码",-1)),v(s("input",{"onUpdate:modelValue":e[1]||(e[1]=i=>n.value=i),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[g,n.value]])]),o.value?(m(),u("p",B,c(o.value),1)):T("",!0),s("button",{type:"submit",class:"btn btn-primary login-btn",disabled:l.value},c(l.value?"登录中...":"进入控制台"),9,D)],32)])])]))}},z=y(M,[["__scopeId","data-v-d42d96d7"]]);export{z as default};
import{_ as y,u as w,c as u,a as d,b as s,d as p,w as V,e as v,v as g,t as c,f as T,r as t,g as N,h as x,o as m,T as A,U}from"./index-9aV50nsX.js";const k={class:"login-page"},C={class:"login-shell"},I={class:"login-aside"},L={class:"login-mark"},R={class:"login-card"},S={class:"form-group"},q={class:"form-group"},B={key:0,class:"form-error"},D=["disabled"],M={__name:"LoginView",setup(E){const f=N(),b=x(),_=w(),a=t(""),n=t(""),o=t(""),l=t(!1);async function h(){o.value="",l.value=!0;try{await _.login(a.value,n.value),f.push(b.query.redirect||"/dashboard")}catch(r){o.value=r.message}finally{l.value=!1}}return(r,e)=>(m(),u("div",k,[d(A,{class:"login-theme-toggle"}),s("div",C,[s("div",I,[s("span",L,[d(U,{name:"bolt",size:22})]),e[2]||(e[2]=s("span",{class:"login-eyebrow"},"AI CHAT / ADMIN",-1)),e[3]||(e[3]=s("h1",null,[p("让系统配置"),s("br"),p("保持清晰可控")],-1)),e[4]||(e[4]=s("p",null,"统一管理用户、模型、权限与会话数据。",-1)),e[5]||(e[5]=s("span",{class:"login-version"},"CONTROL SURFACE · V2",-1))]),s("div",R,[e[8]||(e[8]=s("div",{class:"login-header"},[s("span",{class:"status-dot","aria-hidden":"true"}),s("span",null,"安全入口"),s("h2",null,"登录管理后台"),s("p",null,"请使用管理员账户继续")],-1)),s("form",{onSubmit:V(h,["prevent"])},[s("div",S,[e[6]||(e[6]=s("label",null,"账号",-1)),v(s("input",{"onUpdate:modelValue":e[0]||(e[0]=i=>a.value=i),class:"form-input",placeholder:"管理员用户名或邮箱",required:""},null,512),[[g,a.value]])]),s("div",q,[e[7]||(e[7]=s("label",null,"密码",-1)),v(s("input",{"onUpdate:modelValue":e[1]||(e[1]=i=>n.value=i),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[g,n.value]])]),o.value?(m(),u("p",B,c(o.value),1)):T("",!0),s("button",{type:"submit",class:"btn btn-primary login-btn",disabled:l.value},c(l.value?"登录中...":"进入控制台"),9,D)],32)])])]))}},z=y(M,[["__scopeId","data-v-d42d96d7"]]);export{z as default};
@@ -0,0 +1 @@
.toolbar[data-v-bdc5d18d]{margin-bottom:16px}.url-cell[data-v-bdc5d18d]{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--text-secondary)}code[data-v-bdc5d18d]{background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-size:13px}.test-result[data-v-bdc5d18d]{margin-top:8px;padding:12px;border-radius:8px;font-size:13px}.test-result.success[data-v-bdc5d18d]{background:#22c55e1f;border:1px solid rgba(34,197,94,.3);color:var(--success)}.test-result.error[data-v-bdc5d18d]{background:#ef44441f;border:1px solid rgba(239,68,68,.3);color:var(--danger)}.test-result p[data-v-bdc5d18d]{margin-top:6px;color:var(--text-secondary)}.test-result.success p[data-v-bdc5d18d]{color:#86efac}.reply-preview[data-v-bdc5d18d]{word-break:break-all}.field-hint[data-v-bdc5d18d]{margin-top:4px;font-size:12px;color:var(--text-secondary)}.field-hint-warn[data-v-bdc5d18d]{color:var(--danger)}.saved-key-hint[data-v-bdc5d18d]{margin-bottom:6px;font-size:12px;color:var(--success)}.saved-key-hint code[data-v-bdc5d18d]{background:#22c55e1f;color:var(--success)}.workflow-json[data-v-bdc5d18d]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.45;min-height:180px;resize:vertical}.workflow-actions[data-v-bdc5d18d]{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}.form-row-2[data-v-bdc5d18d]{display:grid;grid-template-columns:1fr 1fr;gap:12px}.btn-sm[data-v-bdc5d18d]{padding:6px 10px;font-size:12px}.file-btn[data-v-bdc5d18d]{cursor:pointer;display:inline-flex;align-items:center}@media(max-width:640px){.form-row-2[data-v-bdc5d18d]{grid-template-columns:1fr}}
@@ -1 +0,0 @@
.toolbar[data-v-16cf4364]{margin-bottom:16px}.url-cell[data-v-16cf4364]{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--text-secondary)}code[data-v-16cf4364]{background:var(--bg-tertiary);padding:2px 6px;border-radius:4px;font-size:13px}.test-result[data-v-16cf4364]{margin-top:8px;padding:12px;border-radius:8px;font-size:13px}.test-result.success[data-v-16cf4364]{background:#22c55e1f;border:1px solid rgba(34,197,94,.3);color:var(--success)}.test-result.error[data-v-16cf4364]{background:#ef44441f;border:1px solid rgba(239,68,68,.3);color:var(--danger)}.test-result p[data-v-16cf4364]{margin-top:6px;color:var(--text-secondary)}.test-result.success p[data-v-16cf4364]{color:#86efac}.reply-preview[data-v-16cf4364]{word-break:break-all}.field-hint[data-v-16cf4364]{margin-top:4px;font-size:12px;color:var(--text-secondary)}.field-hint-warn[data-v-16cf4364]{color:var(--danger)}.saved-key-hint[data-v-16cf4364]{margin-bottom:6px;font-size:12px;color:var(--success)}.saved-key-hint code[data-v-16cf4364]{background:#22c55e1f;color:var(--success)}.workflow-json[data-v-16cf4364]{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.45;min-height:180px;resize:vertical}.workflow-actions[data-v-16cf4364]{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}.form-row-2[data-v-16cf4364]{display:grid;grid-template-columns:1fr 1fr;gap:12px}.btn-sm[data-v-16cf4364]{padding:6px 10px;font-size:12px}.file-btn[data-v-16cf4364]{cursor:pointer;display:inline-flex;align-items:center}@media(max-width:640px){.form-row-2[data-v-16cf4364]{grid-template-columns:1fr}}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.toolbar[data-v-0259ca3f]{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}.empty[data-v-0259ca3f]{text-align:center;padding:32px;color:var(--text-muted)}.type-badge[data-v-0259ca3f]{font-size:11px;padding:2px 6px;border-radius:4px}.type-badge.dir[data-v-0259ca3f]{background:#ffffff0b;color:var(--text-secondary)}.type-badge.menu[data-v-0259ca3f]{background:var(--accent-soft);color:var(--accent)}.type-badge.btn[data-v-0259ca3f]{background:#8fe06a14;color:var(--success)}.path-cell[data-v-0259ca3f]{display:flex;align-items:center;gap:8px;color:var(--text-secondary);font-family:Cascadia Code,Consolas,monospace;font-size:12px}.path-cell[data-v-0259ca3f] svg{color:var(--accent)}.field-hint[data-v-0259ca3f]{margin-top:6px;font-size:12px;color:var(--text-muted)}.danger[data-v-0259ca3f]{color:var(--danger)}
@@ -0,0 +1 @@
.toolbar[data-v-2e1246c9]{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}.empty[data-v-2e1246c9]{text-align:center;padding:32px;color:var(--text-muted)}.type-badge[data-v-2e1246c9]{font-size:11px;padding:2px 6px;border-radius:4px}.type-badge.dir[data-v-2e1246c9]{background:#ffffff0b;color:var(--text-secondary)}.type-badge.menu[data-v-2e1246c9]{background:var(--accent-soft);color:var(--accent)}.type-badge.btn[data-v-2e1246c9]{background:#8fe06a14;color:var(--success)}.path-cell[data-v-2e1246c9]{display:flex;align-items:center;gap:8px;color:var(--text-secondary);font-family:Cascadia Code,Consolas,monospace;font-size:12px}.path-cell[data-v-2e1246c9] svg{color:var(--accent)}.field-hint[data-v-2e1246c9]{margin-top:6px;font-size:12px;color:var(--text-muted)}.danger[data-v-2e1246c9]{color:var(--danger)}
@@ -1 +0,0 @@
.toolbar[data-v-b204ae6d]{margin-bottom:16px}.perm-tags[data-v-b204ae6d]{display:flex;flex-wrap:wrap;gap:4px}.perm-tag[data-v-b204ae6d]{font-size:12px;padding:2px 8px;background:var(--accent-soft);color:var(--accent);border-radius:4px}.field-hint[data-v-b204ae6d]{font-size:12px;color:var(--text-muted)}.modal-wide[data-v-b204ae6d]{max-width:640px;max-height:90vh;display:flex;flex-direction:column}.modal-wide .modal-body[data-v-b204ae6d]{overflow-y:auto}.top-check[data-v-b204ae6d]{font-weight:500}.perm-section[data-v-b204ae6d]{border:1px solid var(--border);border-radius:10px;padding:12px;background:var(--bg-tertiary)}.perm-section-header[data-v-b204ae6d]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:13px;font-weight:500}.perm-actions[data-v-b204ae6d]{display:flex;gap:4px}.perm-dir[data-v-b204ae6d]{margin-bottom:14px;padding-bottom:10px;border-bottom:1px dashed var(--border)}.perm-dir[data-v-b204ae6d]:last-child{border-bottom:none;margin-bottom:0}.dir-check[data-v-b204ae6d]{font-weight:600;margin-bottom:8px}.perm-menu[data-v-b204ae6d]{margin-left:22px;margin-bottom:8px}.perm-btns[data-v-b204ae6d]{margin-left:24px;display:flex;flex-direction:column;gap:4px}.btn-check[data-v-b204ae6d]{font-size:13px;color:var(--text-secondary)}.check-item[data-v-b204ae6d]{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:14px;cursor:pointer}.type-badge[data-v-b204ae6d]{font-size:11px;padding:1px 6px;border-radius:4px;font-weight:500}.type-badge.dir[data-v-b204ae6d]{background:#b7f36b12;color:var(--text-secondary)}.type-badge.menu[data-v-b204ae6d]{background:var(--accent-soft);color:var(--accent)}.type-badge.btn[data-v-b204ae6d]{background:#8fe06a14;color:var(--success)}.danger[data-v-b204ae6d]{color:var(--danger)}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.toolbar[data-v-f4d935cd]{margin-bottom:16px}.perm-tags[data-v-f4d935cd]{display:flex;flex-wrap:wrap;gap:4px}.perm-tag[data-v-f4d935cd]{font-size:12px;padding:2px 8px;background:var(--accent-soft);color:var(--accent);border-radius:4px}.field-hint[data-v-f4d935cd]{font-size:12px;color:var(--text-muted)}.modal-wide[data-v-f4d935cd]{max-width:640px;max-height:90vh;display:flex;flex-direction:column}.modal-wide .modal-body[data-v-f4d935cd]{overflow-y:auto}.top-check[data-v-f4d935cd]{font-weight:500}.perm-section[data-v-f4d935cd]{border:1px solid var(--border);border-radius:10px;padding:12px;background:var(--bg-tertiary)}.perm-section-header[data-v-f4d935cd]{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;font-size:13px;font-weight:500}.perm-actions[data-v-f4d935cd]{display:flex;gap:4px}.perm-dir[data-v-f4d935cd]{margin-bottom:14px;padding-bottom:10px;border-bottom:1px dashed var(--border)}.perm-dir[data-v-f4d935cd]:last-child{border-bottom:none;margin-bottom:0}.dir-check[data-v-f4d935cd]{font-weight:600;margin-bottom:8px}.perm-menu[data-v-f4d935cd]{margin-left:22px;margin-bottom:8px}.perm-btns[data-v-f4d935cd]{margin-left:24px;display:flex;flex-direction:column;gap:4px}.btn-check[data-v-f4d935cd]{font-size:13px;color:var(--text-secondary)}.check-item[data-v-f4d935cd]{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:14px;cursor:pointer}.type-badge[data-v-f4d935cd]{font-size:11px;padding:1px 6px;border-radius:4px;font-weight:500}.type-badge.dir[data-v-f4d935cd]{background:#b7f36b12;color:var(--text-secondary)}.type-badge.menu[data-v-f4d935cd]{background:var(--accent-soft);color:var(--accent)}.type-badge.btn[data-v-f4d935cd]{background:#8fe06a14;color:var(--success)}.danger[data-v-f4d935cd]{color:var(--danger)}
@@ -1 +0,0 @@
.section-title[data-v-0509a01f]{margin-bottom:16px;font-size:16px}.section-desc[data-v-0509a01f],.field-hint[data-v-0509a01f]{color:var(--text-secondary);font-size:12px}.section-desc[data-v-0509a01f]{margin-bottom:16px;font-size:13px}.voice-persona-panel[data-v-0509a01f],.feature-panel[data-v-0509a01f]{margin-top:16px}.persona-heading[data-v-0509a01f]{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding-bottom:20px;border-bottom:1px solid var(--border)}.persona-preview[data-v-0509a01f]{display:flex;align-items:center;gap:14px;min-width:0}.persona-avatar[data-v-0509a01f]{width:54px;height:54px;flex:0 0 54px;border-radius:18px;display:grid;place-items:center;border:1px solid rgba(183,243,107,.42);background:linear-gradient(145deg,var(--accent-hover),var(--accent));box-shadow:inset 0 1px #ffffff73,0 5px #577c2f,0 13px 26px #6ea63629;color:#11150e;font-size:22px;font-weight:700}.persona-kicker[data-v-0509a01f]{color:var(--accent);font-size:10px;font-weight:700;letter-spacing:.13em}.persona-preview h3[data-v-0509a01f]{margin-top:3px;font-size:18px}.persona-preview p[data-v-0509a01f]{margin-top:4px;color:var(--text-secondary);font-size:13px;line-height:1.5}.enable-switch[data-v-0509a01f]{display:inline-flex;align-items:center;gap:8px;padding:8px 11px;border:1px solid var(--border);border-radius:999px;color:var(--text-secondary);font-size:12px;white-space:nowrap}.enable-switch input[data-v-0509a01f],.feature-item input[data-v-0509a01f],.check-item input[data-v-0509a01f]{accent-color:var(--accent)}.preset-row[data-v-0509a01f]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:18px 0}.preset-row>span[data-v-0509a01f]{margin-right:4px;color:var(--text-secondary);font-size:12px}.preset-btn[data-v-0509a01f]{padding:7px 11px;border:1px solid var(--border);border-radius:8px;background:#ffffff05;color:var(--text-secondary);font-size:12px}.preset-btn[data-v-0509a01f]:hover{border-color:#b7f36b66;background:var(--accent-soft);color:var(--accent)}.form-grid[data-v-0509a01f]{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-grid-3[data-v-0509a01f]{grid-template-columns:1fr 1fr .7fr}.persona-textarea[data-v-0509a01f]{min-height:86px;resize:vertical;line-height:1.55}.field-hint[data-v-0509a01f]{margin-top:6px;line-height:1.5}.input-suffix[data-v-0509a01f]{position:relative}.input-suffix input[data-v-0509a01f]{padding-right:38px}.input-suffix span[data-v-0509a01f]{position:absolute;top:50%;right:12px;color:var(--text-muted);font-size:12px;transform:translateY(-50%)}.reference-box[data-v-0509a01f]{margin-bottom:16px;padding:14px;border:1px dashed var(--border-strong);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:16px;background:#b7f36b09}.reference-box strong[data-v-0509a01f]{font-size:13px}.reference-box p[data-v-0509a01f]{margin-top:4px;color:var(--text-secondary);font-size:12px;line-height:1.5}.reference-btn[data-v-0509a01f]{flex:0 0 auto;border:1px solid var(--border)}.persona-actions[data-v-0509a01f]{min-height:38px;display:flex;align-items:center;flex-wrap:wrap;gap:12px}.preview-btn[data-v-0509a01f]{border:1px solid var(--border-strong)}.preview-audio[data-v-0509a01f]{width:min(320px,100%);height:36px}.voice-status[data-v-0509a01f]{color:var(--success);font-size:12px}.voice-status.error[data-v-0509a01f]{color:var(--danger)}.feature-grid[data-v-0509a01f]{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px}.feature-item[data-v-0509a01f],.check-item[data-v-0509a01f]{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px}.success-msg[data-v-0509a01f]{margin-top:12px;color:var(--success);font-size:14px}@media(max-width:760px){.persona-heading[data-v-0509a01f],.reference-box[data-v-0509a01f]{align-items:stretch;flex-direction:column}.enable-switch[data-v-0509a01f]{align-self:flex-start}.form-grid[data-v-0509a01f],.form-grid-3[data-v-0509a01f]{grid-template-columns:1fr}}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.section-title[data-v-c3e4aba4]{margin-bottom:16px;font-size:16px}.section-desc[data-v-c3e4aba4],.field-hint[data-v-c3e4aba4]{color:var(--text-secondary);font-size:12px}.section-desc[data-v-c3e4aba4]{margin-bottom:16px;font-size:13px}.voice-persona-panel[data-v-c3e4aba4],.feature-panel[data-v-c3e4aba4]{margin-top:16px}.persona-heading[data-v-c3e4aba4]{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;padding-bottom:20px;border-bottom:1px solid var(--border)}.persona-preview[data-v-c3e4aba4]{display:flex;align-items:center;gap:14px;min-width:0}.persona-avatar[data-v-c3e4aba4]{width:54px;height:54px;flex:0 0 54px;border-radius:18px;display:grid;place-items:center;border:1px solid rgba(183,243,107,.42);background:linear-gradient(145deg,var(--accent-hover),var(--accent));box-shadow:inset 0 1px #ffffff73,0 5px #577c2f,0 13px 26px #6ea63629;color:#11150e;font-size:22px;font-weight:700}.persona-kicker[data-v-c3e4aba4]{color:var(--accent);font-size:10px;font-weight:700;letter-spacing:.13em}.persona-preview h3[data-v-c3e4aba4]{margin-top:3px;font-size:18px}.persona-preview p[data-v-c3e4aba4]{margin-top:4px;color:var(--text-secondary);font-size:13px;line-height:1.5}.enable-switch[data-v-c3e4aba4]{display:inline-flex;align-items:center;gap:8px;padding:8px 11px;border:1px solid var(--border);border-radius:999px;color:var(--text-secondary);font-size:12px;white-space:nowrap}.enable-switch input[data-v-c3e4aba4],.feature-item input[data-v-c3e4aba4],.check-item input[data-v-c3e4aba4]{accent-color:var(--accent)}.preset-row[data-v-c3e4aba4]{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin:18px 0}.preset-row>span[data-v-c3e4aba4]{margin-right:4px;color:var(--text-secondary);font-size:12px}.preset-btn[data-v-c3e4aba4]{padding:7px 11px;border:1px solid var(--border);border-radius:8px;background:#ffffff05;color:var(--text-secondary);font-size:12px}.preset-btn[data-v-c3e4aba4]:hover{border-color:#b7f36b66;background:var(--accent-soft);color:var(--accent)}.form-grid[data-v-c3e4aba4]{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form-grid-3[data-v-c3e4aba4]{grid-template-columns:1fr 1fr .7fr}.persona-textarea[data-v-c3e4aba4]{min-height:86px;resize:vertical;line-height:1.55}.field-hint[data-v-c3e4aba4]{margin-top:6px;line-height:1.5}.input-suffix[data-v-c3e4aba4]{position:relative}.input-suffix input[data-v-c3e4aba4]{padding-right:38px}.input-suffix span[data-v-c3e4aba4]{position:absolute;top:50%;right:12px;color:var(--text-muted);font-size:12px;transform:translateY(-50%)}.reference-box[data-v-c3e4aba4]{margin-bottom:16px;padding:14px;border:1px dashed var(--border-strong);border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:16px;background:#b7f36b09}.reference-box strong[data-v-c3e4aba4]{font-size:13px}.reference-box p[data-v-c3e4aba4]{margin-top:4px;color:var(--text-secondary);font-size:12px;line-height:1.5}.reference-btn[data-v-c3e4aba4]{flex:0 0 auto;border:1px solid var(--border)}.persona-actions[data-v-c3e4aba4]{min-height:38px;display:flex;align-items:center;flex-wrap:wrap;gap:12px}.preview-btn[data-v-c3e4aba4]{border:1px solid var(--border-strong)}.preview-audio[data-v-c3e4aba4]{width:min(320px,100%);height:36px}.voice-status[data-v-c3e4aba4]{color:var(--success);font-size:12px}.voice-status.error[data-v-c3e4aba4]{color:var(--danger)}.feature-grid[data-v-c3e4aba4]{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px}.feature-item[data-v-c3e4aba4],.check-item[data-v-c3e4aba4]{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px}.success-msg[data-v-c3e4aba4]{margin-top:12px;color:var(--success);font-size:14px}@media(max-width:760px){.persona-heading[data-v-c3e4aba4],.reference-box[data-v-c3e4aba4]{align-items:stretch;flex-direction:column}.enable-switch[data-v-c3e4aba4]{align-self:flex-start}.form-grid[data-v-c3e4aba4],.form-grid-3[data-v-c3e4aba4]{grid-template-columns:1fr}}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Chat 管理后台</title>
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<script type="module" crossorigin src="/admin/assets/index-BZEF5hZc.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-B4BiP-qK.css">
<script type="module" crossorigin src="/admin/assets/index-9aV50nsX.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-DVjG14Yi.css">
</head>
<body>
<div id="app"></div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
import{q as a}from"./index-CTF1v35B.js";/**
* @license @tabler/icons-vue v3.45.0 - MIT
*
* This source code is licensed under the MIT license.
* See the LICENSE file in the root directory of this source tree.
*/var t=a("outline","plus","Plus",[["path",{d:"M12 5l0 14",key:"svg-0"}],["path",{d:"M5 12l14 0",key:"svg-1"}]]);/**
* @license @tabler/icons-vue v3.45.0 - MIT
*
* This source code is licensed under the MIT license.
* See the LICENSE file in the root directory of this source tree.
*/var v=a("outline","trash","Trash",[["path",{d:"M4 7l16 0",key:"svg-0"}],["path",{d:"M10 11l0 6",key:"svg-1"}],["path",{d:"M14 11l0 6",key:"svg-2"}],["path",{d:"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",key:"svg-3"}],["path",{d:"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",key:"svg-4"}]]);/**
* @license @tabler/icons-vue v3.45.0 - MIT
*
* This source code is licensed under the MIT license.
* See the LICENSE file in the root directory of this source tree.
*/var s=a("outline","file-text","FileText",[["path",{d:"M14 3v4a1 1 0 0 0 1 1h4",key:"svg-0"}],["path",{d:"M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2",key:"svg-1"}],["path",{d:"M9 9l1 0",key:"svg-2"}],["path",{d:"M9 13l6 0",key:"svg-3"}],["path",{d:"M9 17l6 0",key:"svg-4"}]]);/**
* @license @tabler/icons-vue v3.45.0 - MIT
*
* This source code is licensed under the MIT license.
* See the LICENSE file in the root directory of this source tree.
*/var h=a("outline","movie","Movie",[["path",{d:"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2l0 -12",key:"svg-0"}],["path",{d:"M8 4l0 16",key:"svg-1"}],["path",{d:"M16 4l0 16",key:"svg-2"}],["path",{d:"M4 8l4 0",key:"svg-3"}],["path",{d:"M4 16l4 0",key:"svg-4"}],["path",{d:"M4 12l16 0",key:"svg-5"}],["path",{d:"M16 8l4 0",key:"svg-6"}],["path",{d:"M16 16l4 0",key:"svg-7"}]]);export{t as I,v as a,s as b,h as c};
@@ -1 +0,0 @@
import{_ as V,u as k,o as E,c as v,a as i,b as t,d as f,e,t as d,w as S,f as c,v as g,g as x,h as A,r as l,i as I,j as N,k as C,l as h}from"./index-sdqi2xzF.js";import{u as M,I as q,T as B}from"./settings-R2Yjxl-Z.js";const L={class:"auth-page"},R={class:"auth-shell"},D={class:"auth-story"},G={class:"auth-logo"},P={class:"auth-card"},U={class:"auth-header"},j={class:"form-group"},z={class:"form-group"},X={key:0,class:"form-error"},F=["disabled"],H={class:"auth-footer"},J={__name:"LoginView",setup(K){const _=N(),b=C(),w=k(),p=M(),n=l(""),u=l(""),o=l(""),a=l(!1);E(()=>p.loadPublic());async function y(){o.value="",a.value=!0;try{await w.login(n.value,u.value),_.push(b.query.redirect||"/")}catch(m){o.value=m.message}finally{a.value=!1}}return(m,s)=>{const T=I("router-link");return h(),v("div",L,[i(B,{class:"auth-theme-toggle"}),t("section",R,[t("aside",D,[t("span",G,[i(f(q),{size:22,"stroke-width":1.8})]),s[2]||(s[2]=t("span",{class:"auth-eyebrow"},"AI CREATIVE SPACE",-1)),s[3]||(s[3]=t("h1",null,[e("把想法放进来,"),t("br"),e("让创作自然发生。")],-1)),s[4]||(s[4]=t("p",null,"在同一个工作区完成对话、图像生成与内容整理。",-1)),s[5]||(s[5]=t("span",{class:"auth-note"},"TEXT · IMAGE · AGENT",-1))]),t("div",P,[t("div",U,[s[6]||(s[6]=t("span",{class:"auth-status"},[t("i"),e(" 账户登录")],-1)),t("h2",null,d(f(p).siteName),1),s[7]||(s[7]=t("p",null,"欢迎回来,请登录您的账户",-1))]),t("form",{onSubmit:S(y,["prevent"])},[t("div",j,[s[8]||(s[8]=t("label",null,"账号",-1)),c(t("input",{"onUpdate:modelValue":s[0]||(s[0]=r=>n.value=r),class:"form-input",placeholder:"用户名或邮箱",required:""},null,512),[[g,n.value]])]),t("div",z,[s[9]||(s[9]=t("label",null,"密码",-1)),c(t("input",{"onUpdate:modelValue":s[1]||(s[1]=r=>u.value=r),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[g,u.value]])]),o.value?(h(),v("p",X,d(o.value),1)):x("",!0),t("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:a.value},d(a.value?"登录中...":"进入创作空间"),9,F)],32),t("p",H,[s[11]||(s[11]=e(" 还没有账户? ",-1)),i(T,{to:"/register"},{default:A(()=>[...s[10]||(s[10]=[e("立即注册",-1)])]),_:1})])])])])}}},W=V(J,[["__scopeId","data-v-036d1210"]]);export{W as default};
@@ -0,0 +1 @@
import{_ as V,u as k,a as E,o as S,c as v,b as i,d as t,e as f,f as e,t as d,w as x,g as c,v as g,h as A,i as I,r as l,j as N,k as C,l as M,m as h}from"./index-CTF1v35B.js";import{I as q,T as B}from"./ThemeToggle-BwEr7zqG.js";const L={class:"auth-page"},R={class:"auth-shell"},D={class:"auth-story"},G={class:"auth-logo"},P={class:"auth-card"},U={class:"auth-header"},j={class:"form-group"},z={class:"form-group"},X={key:0,class:"form-error"},F=["disabled"],H={class:"auth-footer"},J={__name:"LoginView",setup(K){const _=C(),b=M(),w=k(),p=E(),n=l(""),u=l(""),o=l(""),a=l(!1);S(()=>p.loadPublic());async function y(){o.value="",a.value=!0;try{await w.login(n.value,u.value),_.push(b.query.redirect||"/")}catch(m){o.value=m.message}finally{a.value=!1}}return(m,s)=>{const T=N("router-link");return h(),v("div",L,[i(B,{class:"auth-theme-toggle"}),t("section",R,[t("aside",D,[t("span",G,[i(f(q),{size:22,"stroke-width":1.8})]),s[2]||(s[2]=t("span",{class:"auth-eyebrow"},"AI CREATIVE SPACE",-1)),s[3]||(s[3]=t("h1",null,[e("把想法放进来,"),t("br"),e("让创作自然发生。")],-1)),s[4]||(s[4]=t("p",null,"在同一个工作区完成对话、图像生成与内容整理。",-1)),s[5]||(s[5]=t("span",{class:"auth-note"},"TEXT · IMAGE · AGENT",-1))]),t("div",P,[t("div",U,[s[6]||(s[6]=t("span",{class:"auth-status"},[t("i"),e(" 账户登录")],-1)),t("h2",null,d(f(p).siteName),1),s[7]||(s[7]=t("p",null,"欢迎回来,请登录您的账户",-1))]),t("form",{onSubmit:x(y,["prevent"])},[t("div",j,[s[8]||(s[8]=t("label",null,"账号",-1)),c(t("input",{"onUpdate:modelValue":s[0]||(s[0]=r=>n.value=r),class:"form-input",placeholder:"用户名或邮箱",required:""},null,512),[[g,n.value]])]),t("div",z,[s[9]||(s[9]=t("label",null,"密码",-1)),c(t("input",{"onUpdate:modelValue":s[1]||(s[1]=r=>u.value=r),type:"password",class:"form-input",placeholder:"请输入密码",required:""},null,512),[[g,u.value]])]),o.value?(h(),v("p",X,d(o.value),1)):A("",!0),t("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:a.value},d(a.value?"登录中...":"进入创作空间"),9,F)],32),t("p",H,[s[11]||(s[11]=e(" 还没有账户? ",-1)),i(T,{to:"/register"},{default:I(()=>[...s[10]||(s[10]=[e("立即注册",-1)])]),_:1})])])])])}}},W=V(J,[["__scopeId","data-v-036d1210"]]);export{W as default};
@@ -1 +0,0 @@
.auth-page[data-v-09353b56]{position:relative;min-height:100dvh;display:grid;place-items:center;padding:28px;overflow:auto;background:radial-gradient(circle at 72% 16%,rgba(183,243,107,.08),transparent 26%),var(--bg-primary)}.auth-theme-toggle[data-v-09353b56]{position:absolute;top:18px;right:18px;z-index:3}.auth-shell[data-v-09353b56]{position:relative;display:grid;width:min(900px,100%);grid-template-columns:1.08fr .92fr;overflow:hidden;border:1px solid var(--border-strong);border-radius:22px;background:var(--bg-secondary);box-shadow:inset 0 1px #ffffff0b,var(--shadow)}.auth-shell[data-v-09353b56]:after{position:absolute;top:0;left:16%;width:36%;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);box-shadow:0 0 17px #b7f36b70;content:""}.auth-story[data-v-09353b56]{position:relative;display:flex;min-height:610px;flex-direction:column;justify-content:center;padding:54px;overflow:hidden;border-right:1px solid var(--border);background:linear-gradient(135deg,rgba(183,243,107,.07),transparent 45%),repeating-linear-gradient(135deg,rgba(255,255,255,.018) 0 1px,transparent 1px 14px),#0c0f13}.auth-logo[data-v-09353b56]{display:grid;width:46px;height:46px;place-items:center;margin-bottom:46px;border:1px solid rgba(183,243,107,.5);border-radius:14px;background:var(--accent);color:#11150e;box-shadow:inset 0 1px #ffffff85,0 5px #55782f,0 14px 28px #6ea6362b}.auth-eyebrow[data-v-09353b56],.auth-note[data-v-09353b56]{color:var(--accent);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.16em}.auth-story h1[data-v-09353b56]{margin:15px 0 19px;font-size:clamp(36px,4.7vw,54px);font-weight:760;letter-spacing:-.06em;line-height:1.04;text-wrap:balance}.auth-story p[data-v-09353b56]{max-width:31ch;color:var(--text-secondary);font-size:14px;line-height:1.75}.auth-note[data-v-09353b56]{position:absolute;bottom:30px;left:54px;color:var(--text-muted);font-size:9px}.auth-card[data-v-09353b56]{display:flex;flex-direction:column;justify-content:center;padding:42px;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.05),transparent 28%),var(--bg-secondary)}.auth-header[data-v-09353b56]{margin-bottom:27px}.auth-status[data-v-09353b56]{display:inline-flex;align-items:center;gap:7px;color:var(--text-muted);font-size:11px;font-weight:650;letter-spacing:.06em}.auth-status i[data-v-09353b56]{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #b7f36b99}.auth-header h2[data-v-09353b56]{margin:12px 0 7px;font-size:27px;letter-spacing:-.045em}.auth-header p[data-v-09353b56],.auth-footer[data-v-09353b56]{color:var(--text-secondary);font-size:13px}.auth-btn[data-v-09353b56]{width:100%;min-height:47px;margin-top:6px}.auth-footer[data-v-09353b56]{margin-top:23px;text-align:center}@media(max-width:720px){.auth-page[data-v-09353b56]{padding:14px}.auth-shell[data-v-09353b56]{grid-template-columns:1fr}.auth-story[data-v-09353b56]{display:none}.auth-card[data-v-09353b56]{min-height:640px;padding:36px 25px}}
@@ -0,0 +1 @@
.auth-page[data-v-4a1b27ff]{position:relative;min-height:100dvh;display:grid;place-items:center;padding:28px;overflow:auto;background:radial-gradient(circle at 72% 16%,rgba(183,243,107,.08),transparent 26%),var(--bg-primary)}.auth-theme-toggle[data-v-4a1b27ff]{position:absolute;top:18px;right:18px;z-index:3}.auth-shell[data-v-4a1b27ff]{position:relative;display:grid;width:min(900px,100%);grid-template-columns:1.08fr .92fr;overflow:hidden;border:1px solid var(--border-strong);border-radius:22px;background:var(--bg-secondary);box-shadow:inset 0 1px #ffffff0b,var(--shadow)}.auth-shell[data-v-4a1b27ff]:after{position:absolute;top:0;left:16%;width:36%;height:1px;background:linear-gradient(90deg,transparent,var(--accent),transparent);box-shadow:0 0 17px #b7f36b70;content:""}.auth-story[data-v-4a1b27ff]{position:relative;display:flex;min-height:610px;flex-direction:column;justify-content:center;padding:54px;overflow:hidden;border-right:1px solid var(--border);background:linear-gradient(135deg,rgba(183,243,107,.07),transparent 45%),repeating-linear-gradient(135deg,rgba(255,255,255,.018) 0 1px,transparent 1px 14px),#0c0f13}.auth-logo[data-v-4a1b27ff]{display:grid;width:46px;height:46px;place-items:center;margin-bottom:46px;border:1px solid rgba(183,243,107,.5);border-radius:14px;background:var(--accent);color:#11150e;box-shadow:inset 0 1px #ffffff85,0 5px #55782f,0 14px 28px #6ea6362b}.auth-eyebrow[data-v-4a1b27ff],.auth-note[data-v-4a1b27ff]{color:var(--accent);font-family:Cascadia Code,Consolas,monospace;font-size:10px;letter-spacing:.16em}.auth-story h1[data-v-4a1b27ff]{margin:15px 0 19px;font-size:clamp(36px,4.7vw,54px);font-weight:760;letter-spacing:-.06em;line-height:1.04;text-wrap:balance}.auth-story p[data-v-4a1b27ff]{max-width:31ch;color:var(--text-secondary);font-size:14px;line-height:1.75}.auth-note[data-v-4a1b27ff]{position:absolute;bottom:30px;left:54px;color:var(--text-muted);font-size:9px}.auth-card[data-v-4a1b27ff]{display:flex;flex-direction:column;justify-content:center;padding:42px;background:radial-gradient(circle at 100% 0%,rgba(183,243,107,.05),transparent 28%),var(--bg-secondary)}.auth-header[data-v-4a1b27ff]{margin-bottom:27px}.auth-status[data-v-4a1b27ff]{display:inline-flex;align-items:center;gap:7px;color:var(--text-muted);font-size:11px;font-weight:650;letter-spacing:.06em}.auth-status i[data-v-4a1b27ff]{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px #b7f36b99}.auth-header h2[data-v-4a1b27ff]{margin:12px 0 7px;font-size:27px;letter-spacing:-.045em}.auth-header p[data-v-4a1b27ff],.auth-footer[data-v-4a1b27ff]{color:var(--text-secondary);font-size:13px}.invitation-input[data-v-4a1b27ff]{font-family:Cascadia Code,Consolas,monospace;letter-spacing:.04em;text-transform:uppercase}.field-help[data-v-4a1b27ff]{display:block;margin-top:7px;color:var(--text-muted);font-size:11px;line-height:1.5}.auth-btn[data-v-4a1b27ff]{width:100%;min-height:47px;margin-top:6px}.auth-footer[data-v-4a1b27ff]{margin-top:23px;text-align:center}@media(max-width:720px){.auth-page[data-v-4a1b27ff]{padding:14px}.auth-shell[data-v-4a1b27ff]{grid-template-columns:1fr}.auth-story[data-v-4a1b27ff]{display:none}.auth-card[data-v-4a1b27ff]{min-height:640px;padding:36px 25px}}
@@ -0,0 +1 @@
import{_ as k,u as N,a as R,o as S,c as h,b as f,d as e,e as w,f as a,t as g,w as x,g as u,v as r,h as A,i as C,r as o,j as E,k as I,m as y}from"./index-CTF1v35B.js";import{I as q,T as B}from"./ThemeToggle-BwEr7zqG.js";const M={class:"auth-page"},U={class:"auth-shell"},D={class:"auth-story"},G={class:"auth-logo"},j={class:"auth-card"},z={class:"auth-header"},P={class:"form-group"},X={class:"form-group"},F={class:"form-group"},H={class:"form-group"},J={key:0,class:"form-error"},K=["disabled"],L={class:"auth-footer"},O={__name:"RegisterView",setup(Q){const T=I(),V=N(),i=R(),d=o(""),p=o(""),m=o(""),v=o(""),l=o(""),n=o(!1);S(()=>i.loadPublic());async function c(){if(!i.allowRegister){l.value="当前不允许注册";return}l.value="",n.value=!0;try{await V.register(d.value,p.value,m.value,v.value),T.push("/")}catch(b){l.value=b.message}finally{n.value=!1}}return(b,s)=>{const _=E("router-link");return y(),h("div",M,[f(B,{class:"auth-theme-toggle"}),e("section",U,[e("aside",D,[e("span",G,[f(w(q),{size:22,"stroke-width":1.8})]),s[4]||(s[4]=e("span",{class:"auth-eyebrow"},"START CREATING",-1)),s[5]||(s[5]=e("h1",null,[a("一个账户,"),e("br"),a("连接全部创作工具。")],-1)),s[6]||(s[6]=e("p",null,"使用管理员签发的邀请码加入指定部门,登录后即可使用全部模型与创作工具。",-1)),s[7]||(s[7]=e("span",{class:"auth-note"},"TEXT · IMAGE · AGENT",-1))]),e("div",j,[e("div",z,[s[8]||(s[8]=e("span",{class:"auth-status"},[e("i"),a(" 创建账户")],-1)),e("h2",null,g(w(i).siteName),1),s[9]||(s[9]=e("p",null,"填写信息,开始新的创作会话",-1))]),e("form",{onSubmit:x(c,["prevent"])},[e("div",P,[s[10]||(s[10]=e("label",null,"用户名",-1)),u(e("input",{"onUpdate:modelValue":s[0]||(s[0]=t=>d.value=t),class:"form-input",placeholder:"3-50 个字符",required:""},null,512),[[r,d.value]])]),e("div",X,[s[11]||(s[11]=e("label",null,"邮箱",-1)),u(e("input",{"onUpdate:modelValue":s[1]||(s[1]=t=>p.value=t),type:"email",class:"form-input",placeholder:"your@email.com",required:""},null,512),[[r,p.value]])]),e("div",F,[s[12]||(s[12]=e("label",null,"密码",-1)),u(e("input",{"onUpdate:modelValue":s[2]||(s[2]=t=>m.value=t),type:"password",class:"form-input",placeholder:"至少 6 位",required:""},null,512),[[r,m.value]])]),e("div",H,[s[13]||(s[13]=e("label",null,"邀请码",-1)),u(e("input",{"onUpdate:modelValue":s[3]||(s[3]=t=>v.value=t),class:"form-input invitation-input",placeholder:"例如 INV-1A2B3C4D5E",autocomplete:"one-time-code",required:""},null,512),[[r,v.value,void 0,{trim:!0}]]),s[14]||(s[14]=e("span",{class:"field-help"},"邀请码仅可使用一次,注册后会自动加入邀请码指定的部门",-1))]),l.value?(y(),h("p",J,g(l.value),1)):A("",!0),e("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:n.value},g(n.value?"注册中...":"创建并进入"),9,K)],32),e("p",L,[s[16]||(s[16]=a(" 已有账户? ",-1)),f(_,{to:"/login"},{default:C(()=>[...s[15]||(s[15]=[a("立即登录",-1)])]),_:1})])])])])}}},Z=k(O,[["__scopeId","data-v-4a1b27ff"]]);export{Z as default};
@@ -1 +0,0 @@
import{_ as V,u as k,o as N,c as b,a as p,b as e,d as h,e as l,t as m,w as R,f as v,v as f,g as S,h as x,r as a,i as A,j as E,l as c}from"./index-sdqi2xzF.js";import{u as I,I as C,T as M}from"./settings-R2Yjxl-Z.js";const q={class:"auth-page"},B={class:"auth-shell"},G={class:"auth-story"},U={class:"auth-logo"},D={class:"auth-card"},j={class:"auth-header"},z={class:"form-group"},P={class:"form-group"},X={class:"form-group"},F={key:0,class:"form-error"},H=["disabled"],J={class:"auth-footer"},K={__name:"RegisterView",setup(L){const _=E(),w=k(),n=I(),r=a(""),i=a(""),d=a(""),t=a(""),u=a(!1);N(()=>n.loadPublic());async function y(){if(!n.allowRegister){t.value="当前不允许注册";return}t.value="",u.value=!0;try{await w.register(r.value,i.value,d.value),_.push("/")}catch(g){t.value=g.message}finally{u.value=!1}}return(g,s)=>{const T=A("router-link");return c(),b("div",q,[p(M,{class:"auth-theme-toggle"}),e("section",B,[e("aside",G,[e("span",U,[p(h(C),{size:22,"stroke-width":1.8})]),s[3]||(s[3]=e("span",{class:"auth-eyebrow"},"START CREATING",-1)),s[4]||(s[4]=e("h1",null,[l("一个账户,"),e("br"),l("连接全部创作工具。")],-1)),s[5]||(s[5]=e("p",null,"创建账户后即可保存会话,并使用可用的模型与 Agent。",-1)),s[6]||(s[6]=e("span",{class:"auth-note"},"TEXT · IMAGE · AGENT",-1))]),e("div",D,[e("div",j,[s[7]||(s[7]=e("span",{class:"auth-status"},[e("i"),l(" 创建账户")],-1)),e("h2",null,m(h(n).siteName),1),s[8]||(s[8]=e("p",null,"填写信息,开始新的创作会话",-1))]),e("form",{onSubmit:R(y,["prevent"])},[e("div",z,[s[9]||(s[9]=e("label",null,"用户名",-1)),v(e("input",{"onUpdate:modelValue":s[0]||(s[0]=o=>r.value=o),class:"form-input",placeholder:"3-50 个字符",required:""},null,512),[[f,r.value]])]),e("div",P,[s[10]||(s[10]=e("label",null,"邮箱",-1)),v(e("input",{"onUpdate:modelValue":s[1]||(s[1]=o=>i.value=o),type:"email",class:"form-input",placeholder:"your@email.com",required:""},null,512),[[f,i.value]])]),e("div",X,[s[11]||(s[11]=e("label",null,"密码",-1)),v(e("input",{"onUpdate:modelValue":s[2]||(s[2]=o=>d.value=o),type:"password",class:"form-input",placeholder:"至少 6 位",required:""},null,512),[[f,d.value]])]),t.value?(c(),b("p",F,m(t.value),1)):S("",!0),e("button",{type:"submit",class:"btn btn-primary auth-btn",disabled:u.value},m(u.value?"注册中...":"创建并进入"),9,H)],32),e("p",J,[s[13]||(s[13]=l(" 已有账户? ",-1)),p(T,{to:"/login"},{default:x(()=>[...s[12]||(s[12]=[l("立即登录",-1)])]),_:1})])])])])}}},W=V(K,[["__scopeId","data-v-09353b56"]]);export{W as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
import{q as n,_ as i,m as p,c as d,d as t,e as a,z as r,S as m}from"./index-CTF1v35B.js";/**
* @license @tabler/icons-vue v3.45.0 - MIT
*
* This source code is licensed under the MIT license.
* See the LICENSE file in the root directory of this source tree.
*/var T=n("outline","sparkles","Sparkles",[["path",{d:"M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6",key:"svg-0"}]]);const u={class:"theme-toggle",role:"group","aria-label":"界面主题"},g=["aria-pressed"],k=["aria-pressed"],_={__name:"ThemeToggle",setup(c){const{theme:s,setTheme:o}=m();return(b,e)=>(p(),d("div",u,[t("button",{type:"button",class:r({active:a(s)==="light"}),"aria-pressed":a(s)==="light","aria-label":"使用浅色主题",title:"浅色主题",onClick:e[0]||(e[0]=l=>a(o)("light"))},[...e[2]||(e[2]=[t("span",null,"浅色",-1)])],10,g),t("button",{type:"button",class:r({active:a(s)==="dark"}),"aria-pressed":a(s)==="dark","aria-label":"使用深色主题",title:"深色主题",onClick:e[1]||(e[1]=l=>a(o)("dark"))},[...e[3]||(e[3]=[t("span",null,"深色",-1)])],10,k)]))}},h=i(_,[["__scopeId","data-v-6644b9d7"]]);export{T as I,h as T};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +0,0 @@
import{p as f,_ as k,l as b,c as h,b as u,d as s,y as g,P as y,m as T,r as t,n as c}from"./index-sdqi2xzF.js";/**
* @license @tabler/icons-vue v3.45.0 - MIT
*
* This source code is licensed under the MIT license.
* See the LICENSE file in the root directory of this source tree.
*/var A=f("outline","sparkles","Sparkles",[["path",{d:"M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m0 -12a2 2 0 0 1 2 2a2 2 0 0 1 2 -2a2 2 0 0 1 -2 -2a2 2 0 0 1 -2 2m-7 12a6 6 0 0 1 6 -6a6 6 0 0 1 -6 -6a6 6 0 0 1 -6 6a6 6 0 0 1 6 6",key:"svg-0"}]]);const w={class:"theme-toggle",role:"group","aria-label":""},C=["aria-pressed"],I=["aria-pressed"],S={__name:"ThemeToggle",setup(d){const{theme:a,setTheme:o}=y();return(n,e)=>(b(),h("div",w,[u("button",{type:"button",class:g({active:s(a)==="light"}),"aria-pressed":s(a)==="light","aria-label":"使",title:"",onClick:e[0]||(e[0]=r=>s(o)("light"))},[...e[2]||(e[2]=[u("span",null,"",-1)])],10,C),u("button",{type:"button",class:g({active:s(a)==="dark"}),"aria-pressed":s(a)==="dark","aria-label":"使",title:"",onClick:e[1]||(e[1]=r=>s(o)("dark"))},[...e[3]||(e[3]=[u("span",null,"",-1)])],10,I)]))}},B=k(S,[["__scopeId","data-v-6644b9d7"]]),P=T("settings",()=>{const d=t({markdown:!0,image:!0,video:!0,voice:!0,document:!0,emoji:!0,upload_image:!0,upload_video:!0,upload_file:!0,paste_image:!0}),a=t("AI Chat"),o=t(!0),n=t({name:"AI ",greeting:""}),e=t([]),r=t([]),p=t(!1);async function m(){const i=(await c.get("/settings/public")).data.data;d.value=i.features,a.value=i.site_name,o.value=i.allow_register,n.value=i.voice_persona||n.value,p.value=!0}async function v(){const l=await c.get("/models");e.value=l.data.data}async function _(){const l=await c.get("/agents");r.value=l.data.data||[]}return{features:d,siteName:a,allowRegister:o,voicePersona:n,models:e,agents:r,loaded:p,loadPublic:m,loadModels:v,loadAgents:_}});export{A as I,B as T,P as u};
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>AI Chat</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="module" crossorigin src="/assets/index-sdqi2xzF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-De9xW-9E.css">
<script type="module" crossorigin src="/assets/index-CTF1v35B.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DOyHbQvS.css">
</head>
<body>
<div id="app"></div>
+17
View File
@@ -49,6 +49,17 @@ Route::group('api', function () {
Route::post('upload', 'api.Upload/upload');
Route::get('models', 'api.Settings/models');
Route::get('agents', 'api.Settings/agents');
Route::get('short-drama/bootstrap', 'api.ShortDrama/bootstrap');
Route::get('short-drama/projects', 'api.ShortDrama/index');
Route::post('short-drama/projects', 'api.ShortDrama/createProject');
Route::get('short-drama/projects/:id', 'api.ShortDrama/show');
Route::delete('short-drama/projects/:id', 'api.ShortDrama/deleteProject');
Route::post('short-drama/projects/:id/characters', 'api.ShortDrama/addCharacter');
Route::delete('short-drama/projects/:id/characters/:characterId', 'api.ShortDrama/deleteCharacter');
Route::post('short-drama/projects/:id/generate', 'api.ShortDrama/generate');
Route::get('short-drama/projects/:id/status', 'api.ShortDrama/status');
Route::post('short-drama/projects/:id/shots/:shotId/retry', 'api.ShortDrama/retryShot');
})->middleware(\app\middleware\JwtAuth::class);
Route::group('admin', function () {
@@ -56,6 +67,9 @@ Route::group('api', function () {
Route::post('users', 'api.Admin/createUser');
Route::put('users/:id', 'api.Admin/updateUser');
Route::delete('users/:id', 'api.Admin/deleteUser');
Route::get('guests', 'api.Admin/guests');
Route::put('guests/:id/status', 'api.Admin/updateGuestStatus');
Route::delete('guests/:id', 'api.Admin/deleteGuest');
Route::get('roles', 'api.Admin/roles');
Route::post('roles', 'api.Admin/createRole');
Route::put('roles/:id', 'api.Admin/updateRole');
@@ -69,6 +83,9 @@ Route::group('api', function () {
Route::post('departments', 'api.Admin/createDepartment');
Route::put('departments/:id', 'api.Admin/updateDepartment');
Route::delete('departments/:id', 'api.Admin/deleteDepartment');
Route::get('invitations', 'api.Admin/invitations');
Route::post('invitations', 'api.Admin/createInvitation');
Route::put('invitations/:id/revoke', 'api.Admin/revokeInvitation');
Route::get('conversations', 'api.Admin/conversations');
Route::get('conversations/:id', 'api.Admin/conversationDetail');
Route::get('stats', 'api.Admin/stats');
@@ -0,0 +1,97 @@
{
"1": {
"_meta": { "title": "MiniMax H3 FL2VA / REF2VA 模型" },
"class_type": "UNETLoader",
"inputs": {
"unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors",
"weight_dtype": "default"
}
},
"2": {
"_meta": { "title": "H3 视频/音频联合采样时间表" },
"class_type": "MiniMaxH3SigmaShift",
"inputs": { "model": ["1", 0], "shift_video": 12.0, "shift_audio": 3.0 }
},
"3": {
"_meta": { "title": "Qwen3-VL H3 文本编码器" },
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors",
"type": "minimax",
"device": "default"
}
},
"4": {
"_meta": { "title": "H3 视频 VAE" },
"class_type": "VAELoader",
"inputs": { "vae_name": "minimax_h3_video_vae_fp16.safetensors" }
},
"5": {
"_meta": { "title": "H3 音频 VAE" },
"class_type": "VAELoader",
"inputs": { "vae_name": "minimax_h3_audio_vae_fp32.safetensors" }
},
"6": {
"_meta": { "title": "H3 FL2VA / 首帧续拍联合音画条件" },
"class_type": "MiniMaxH3ImageToVideo",
"inputs": {
"clip": ["3", 0],
"vae": ["4", 0],
"prompt": "MINIMAX H3 NATIVE JOINT AUDIO-VIDEO CHARACTER DIALOGUE. The visible protagonist, and nobody else, speaks exact standard Mandarin Chinese: ‘有鬼啊!’. The speaking voice must match the visible speakers sex, age and identity. No narrator, no second voice, no extra words, no pseudo-language, no subtitles.",
"width": 576,
"height": 1024,
"length": 124
}
},
"7": {
"_meta": { "title": "零负向条件" },
"class_type": "ConditioningZeroOut",
"inputs": { "conditioning": ["6", 0] }
},
"8": {
"_meta": { "title": "H3 联合视频+音频采样器" },
"class_type": "KSampler",
"inputs": {
"model": ["2", 0],
"seed": 20260804,
"steps": 16,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["6", 1],
"denoise": 1.0
}
},
"9": {
"_meta": { "title": "拆分联合视频/音频潜变量" },
"class_type": "LTXVSeparateAVLatent",
"inputs": { "av_latent": ["8", 0] }
},
"10": {
"_meta": { "title": "解码视频画面" },
"class_type": "VAEDecode",
"inputs": { "samples": ["9", 0], "vae": ["4", 0] }
},
"11": {
"_meta": { "title": "解码 H3 原生同步音轨" },
"class_type": "VAEDecodeAudio",
"inputs": { "samples": ["9", 1], "vae": ["5", 0] }
},
"12": {
"_meta": { "title": "24fps 联合音画封装" },
"class_type": "CreateVideo",
"inputs": { "images": ["10", 0], "fps": 24.0, "audio": ["11", 0], "bit_depth": 8 }
},
"13": {
"_meta": { "title": "保存 H3 联合音画 MP4" },
"class_type": "SaveVideo",
"inputs": {
"video": ["12", 0],
"filename_prefix": "short_drama/H3_AV_V2_",
"format": "mp4",
"codec": "auto"
}
}
}
@@ -0,0 +1,358 @@
-- MySQL dump 10.13 Distrib 8.4.11, for Linux (aarch64)
--
-- Host: localhost Database: ai_chat
-- ------------------------------------------------------
-- Server version 8.4.11
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `ai_models`
--
DROP TABLE IF EXISTS `ai_models`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ai_models` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`provider` varchar(20) NOT NULL DEFAULT 'openai' COMMENT '接口类型:openai=OpenAI兼容协议,dify=Dify应用API',
`model_id` varchar(100) NOT NULL COMMENT 'API model 参数(Dify 模式下可留空,应用由 API Key 决定)',
`api_base_url` varchar(255) NOT NULL DEFAULT 'https://api.openai.com/v1',
`api_key` varchar(255) NOT NULL,
`max_tokens` int unsigned DEFAULT '4096',
`temperature` decimal(3,2) DEFAULT '0.70',
`is_default` tinyint(1) DEFAULT '0',
`enabled` tinyint(1) DEFAULT '1',
`support_context` tinyint(1) DEFAULT '1' COMMENT '是否支持上下文(多轮对话),关闭则每次只发送当前消息',
`support_image` tinyint(1) DEFAULT '1' COMMENT '是否支持图片/多模态输入,关闭则图片不会发送给模型',
`frequency_penalty` decimal(3,2) DEFAULT '0.00' COMMENT '频率惩罚,值越大越能抑制模型重复输出相同内容(部分自部署模型容易陷入重复循环)',
`presence_penalty` decimal(3,2) DEFAULT '0.00' COMMENT '存在惩罚,抑制模型重复讨论相同话题/短语',
`extra_config` json DEFAULT NULL COMMENT '扩展配置(ComfyUI 工作流 JSON、节点映射等)',
`sort_order` int DEFAULT '0',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `ai_models`
--
LOCK TABLES `ai_models` WRITE;
/*!40000 ALTER TABLE `ai_models` DISABLE KEYS */;
INSERT INTO `ai_models` VALUES (1,'GPT-4o Mini','openai','gpt-4o-mini','https://api.openai.com/v1','your-api-key-here',4096,0.70,1,1,1,1,0.00,0.00,NULL,0,'2026-08-03 02:38:43','2026-08-03 02:38:43');
/*!40000 ALTER TABLE `ai_models` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `conversations`
--
DROP TABLE IF EXISTS `conversations`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `conversations` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL,
`title` varchar(200) DEFAULT '新对话',
`model_id` int unsigned DEFAULT NULL,
`is_pinned` tinyint(1) DEFAULT '0',
`message_count` int unsigned DEFAULT '0',
`external_conversation_id` varchar(64) DEFAULT NULL COMMENT '第三方平台(如 Dify)自己维护的会话ID,用于保持上下文连续',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `model_id` (`model_id`),
KEY `idx_user_updated` (`user_id`,`updated_at` DESC),
CONSTRAINT `conversations_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `conversations_ibfk_2` FOREIGN KEY (`model_id`) REFERENCES `ai_models` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `conversations`
--
LOCK TABLES `conversations` WRITE;
/*!40000 ALTER TABLE `conversations` DISABLE KEYS */;
INSERT INTO `conversations` VALUES (1,1,'新对话',1,0,0,NULL,'2026-08-03 02:58:18','2026-08-03 02:58:18',NULL);
/*!40000 ALTER TABLE `conversations` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `departments`
--
DROP TABLE IF EXISTS `departments`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `departments` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`parent_id` int unsigned DEFAULT NULL,
`sort_order` int DEFAULT '0',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_parent` (`parent_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `departments`
--
LOCK TABLES `departments` WRITE;
/*!40000 ALTER TABLE `departments` DISABLE KEYS */;
INSERT INTO `departments` VALUES (1,'总公司',NULL,0,'2026-08-03 02:38:43','2026-08-03 02:53:39');
/*!40000 ALTER TABLE `departments` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `membership_levels`
--
DROP TABLE IF EXISTS `membership_levels`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `membership_levels` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`slug` varchar(50) NOT NULL,
`max_conversations` int unsigned DEFAULT '50',
`max_messages_per_day` int unsigned DEFAULT '100',
`max_upload_size_mb` int unsigned DEFAULT '10',
`allowed_models` json DEFAULT NULL COMMENT '允许使用的模型ID列表,null表示全部',
`permissions` json DEFAULT NULL COMMENT '额外权限配置',
`sort_order` int DEFAULT '0',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `slug` (`slug`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `membership_levels`
--
LOCK TABLES `membership_levels` WRITE;
/*!40000 ALTER TABLE `membership_levels` DISABLE KEYS */;
INSERT INTO `membership_levels` VALUES (1,'免费用户','free',20,50,5,NULL,'{\"can_use_voice\": false, \"can_upload_file\": false, \"can_upload_image\": true, \"can_upload_video\": false}',0,'2026-08-03 02:38:43','2026-08-03 02:53:39'),(2,'高级会员','premium',200,500,50,NULL,'{\"can_use_voice\": true, \"can_upload_file\": true, \"can_upload_image\": true, \"can_upload_video\": true}',0,'2026-08-03 02:38:43','2026-08-03 02:38:43'),(3,'管理员','admin',9999,9999,100,NULL,'{\"can_use_voice\": true, \"can_upload_file\": true, \"can_upload_image\": true, \"can_upload_video\": true}',0,'2026-08-03 02:38:43','2026-08-03 02:53:39');
/*!40000 ALTER TABLE `membership_levels` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `messages`
--
DROP TABLE IF EXISTS `messages`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `messages` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`conversation_id` int unsigned NOT NULL,
`role` enum('user','assistant','system') NOT NULL,
`content` text NOT NULL,
`content_type` enum('text','markdown','mixed') DEFAULT 'text',
`attachments` json DEFAULT NULL COMMENT '[{type,url,name,size,mime}]',
`tokens_used` int unsigned DEFAULT '0',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_conversation` (`conversation_id`,`created_at`),
CONSTRAINT `messages_ibfk_1` FOREIGN KEY (`conversation_id`) REFERENCES `conversations` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `messages`
--
LOCK TABLES `messages` WRITE;
/*!40000 ALTER TABLE `messages` DISABLE KEYS */;
/*!40000 ALTER TABLE `messages` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `roles`
--
DROP TABLE IF EXISTS `roles`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `roles` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`slug` varchar(50) NOT NULL,
`permissions` json DEFAULT NULL COMMENT '后台权限配置',
`sort_order` int DEFAULT '0',
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `slug` (`slug`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `roles`
--
LOCK TABLES `roles` WRITE;
/*!40000 ALTER TABLE `roles` DISABLE KEYS */;
INSERT INTO `roles` VALUES (1,'超级管理员','super_admin','{\"dirs\": [\"dir:overview\", \"dir:org\", \"dir:business\", \"dir:system\"], \"menus\": [\"menu:dashboard\", \"menu:users\", \"menu:departments\", \"menu:roles\", \"menu:conversations\", \"menu:memberships\", \"menu:models\", \"menu:settings\"], \"buttons\": [\"btn:user:edit\", \"btn:user:reset_password\", \"btn:dept:create\", \"btn:dept:edit\", \"btn:dept:delete\", \"btn:role:create\", \"btn:role:edit\", \"btn:role:delete\", \"btn:conv:view_all\", \"btn:conv:view_subordinate\", \"btn:membership:edit\", \"btn:model:create\", \"btn:model:edit\", \"btn:model:delete\", \"btn:model:test\", \"btn:settings:save\"], \"can_access_admin\": true, \"can_manage_roles\": true, \"can_manage_users\": true, \"can_manage_models\": true, \"can_manage_settings\": true, \"can_manage_departments\": true, \"can_manage_memberships\": true, \"can_view_all_conversations\": true, \"can_view_subordinate_conversations\": true}',1,'2026-08-03 02:38:43','2026-08-03 02:53:39'),(2,'部门管理员','dept_manager','{\"dirs\": [\"dir:overview\", \"dir:business\"], \"menus\": [\"menu:dashboard\", \"menu:conversations\"], \"buttons\": [\"btn:conv:view_subordinate\"], \"can_access_admin\": true, \"can_manage_roles\": false, \"can_manage_users\": false, \"can_manage_models\": false, \"can_manage_settings\": false, \"can_manage_departments\": false, \"can_manage_memberships\": false, \"can_view_all_conversations\": false, \"can_view_subordinate_conversations\": true}',2,'2026-08-03 02:38:43','2026-08-03 02:53:39'),(3,'普通用户','user','{\"dirs\": [], \"menus\": [], \"buttons\": [], \"can_access_admin\": false, \"can_manage_roles\": false, \"can_manage_users\": false, \"can_manage_models\": false, \"can_manage_settings\": false, \"can_manage_departments\": false, \"can_manage_memberships\": false, \"can_view_all_conversations\": false, \"can_view_subordinate_conversations\": false}',3,'2026-08-03 02:38:43','2026-08-03 02:53:39');
/*!40000 ALTER TABLE `roles` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `system_settings`
--
DROP TABLE IF EXISTS `system_settings`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `system_settings` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`setting_key` varchar(100) NOT NULL,
`setting_value` text NOT NULL,
`description` varchar(255) DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `setting_key` (`setting_key`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `system_settings`
--
LOCK TABLES `system_settings` WRITE;
/*!40000 ALTER TABLE `system_settings` DISABLE KEYS */;
INSERT INTO `system_settings` VALUES (1,'features','{\"markdown\":true,\"image\":true,\"video\":true,\"voice\":true,\"document\":true,\"emoji\":true,\"upload_image\":true,\"upload_video\":true,\"upload_file\":true,\"paste_image\":true}','功能开关','2026-08-03 02:53:39'),(2,'site_name','AI Chat','站点名称','2026-08-03 02:53:39'),(3,'allow_register','true','是否允许注册','2026-08-03 02:53:39');
/*!40000 ALTER TABLE `system_settings` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `uploads`
--
DROP TABLE IF EXISTS `uploads`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `uploads` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL,
`original_name` varchar(255) NOT NULL,
`stored_name` varchar(255) NOT NULL,
`file_path` varchar(500) NOT NULL,
`mime_type` varchar(100) NOT NULL,
`file_size` int unsigned NOT NULL,
`file_type` enum('image','video','audio','document','other') NOT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
CONSTRAINT `uploads_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `uploads`
--
LOCK TABLES `uploads` WRITE;
/*!40000 ALTER TABLE `uploads` DISABLE KEYS */;
/*!40000 ALTER TABLE `uploads` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `user_daily_stats`
--
DROP TABLE IF EXISTS `user_daily_stats`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `user_daily_stats` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL,
`stat_date` date NOT NULL,
`message_count` int unsigned DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_date` (`user_id`,`stat_date`),
CONSTRAINT `user_daily_stats_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `user_daily_stats`
--
LOCK TABLES `user_daily_stats` WRITE;
/*!40000 ALTER TABLE `user_daily_stats` DISABLE KEYS */;
/*!40000 ALTER TABLE `user_daily_stats` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Table structure for table `users`
--
DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `users` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`email` varchar(100) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`nickname` varchar(50) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`role` enum('user','admin') DEFAULT 'user',
`role_id` int unsigned DEFAULT NULL,
`department_id` int unsigned DEFAULT NULL,
`membership_level_id` int unsigned DEFAULT '1',
`status` enum('active','disabled') DEFAULT 'active',
`last_login_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`),
UNIQUE KEY `email` (`email`),
KEY `membership_level_id` (`membership_level_id`),
CONSTRAINT `users_ibfk_1` FOREIGN KEY (`membership_level_id`) REFERENCES `membership_levels` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `users`
--
LOCK TABLES `users` WRITE;
/*!40000 ALTER TABLE `users` DISABLE KEYS */;
INSERT INTO `users` VALUES (1,'admin','admin@example.com','$2y$10$VJCJHjJoAdUAENQZzLgKO.DrJaVsquBXGWSg5ok3u2FHAKRkVMWX.','管理员',NULL,'admin',1,1,3,'active','2026-08-03 02:55:11','2026-08-03 02:38:43','2026-08-03 02:55:11'),(2,'guest_c4772e915f8bde2132119237b2049ee3','guest_c4772e915f8bde2132119237b2049ee3@guest.local','$2y$10$zOSDCypCQL..1FENZ8p/..tyNFr1gGA8dnh8XFnqmGte5qVJu9VAe','访客',NULL,'user',3,NULL,1,'active',NULL,'2026-08-03 02:48:18','2026-08-03 02:48:18');
/*!40000 ALTER TABLE `users` ENABLE KEYS */;
UNLOCK TABLES;
--
-- Dumping routines for database 'ai_chat'
--
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-08-04 9:36:16
+74
View File
@@ -0,0 +1,74 @@
name: ai-chat
services:
db:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
MYSQL_DATABASE: ai_chat
MYSQL_USER: ${DB_USER:-ai_chat}
MYSQL_PASSWORD: ${DB_PASSWORD:-chat_password}
TZ: Asia/Shanghai
volumes:
- mysql-data:/var/lib/mysql
- ./backend/database/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 5s
timeout: 5s
retries: 30
start_period: 20s
app:
build:
context: .
target: app
restart: unless-stopped
environment:
APP_DEBUG: ${APP_DEBUG:-false}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:8081}
DEFAULT_LANG: zh-cn
DB_TYPE: mysql
DB_HOST: db
DB_NAME: ai_chat
DB_USER: ${DB_USER:-ai_chat}
DB_PASS: ${DB_PASSWORD:-chat_password}
DB_PORT: 3306
DB_CHARSET: utf8mb4
JWT_SECRET: ${JWT_SECRET:-docker-development-secret-change-me}
JWT_EXPIRE: ${JWT_EXPIRE:-604800}
COSYVOICE_ENABLED: ${COSYVOICE_ENABLED:-false}
COSYVOICE_BASE_URL: ${COSYVOICE_BASE_URL:-http://host.docker.internal:50000}
SHORT_DRAMA_TTS_BASE_URL: ${SHORT_DRAMA_TTS_BASE_URL:-http://192.168.110.111:50000}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- uploads-data:/var/www/backend/uploads
- runtime-data:/var/www/backend/runtime
- storage-data:/var/www/backend/storage
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "php", "-r", "$$socket=@fsockopen('127.0.0.1',9000); exit($$socket?0:1);"]
interval: 10s
timeout: 3s
retries: 10
web:
build:
context: .
target: web
restart: unless-stopped
ports:
- "${APP_PORT:-8081}:80"
depends_on:
app:
condition: service_healthy
volumes:
mysql-data:
uploads-data:
runtime-data:
storage-data:
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -eu
php database/migrate_invitation_codes.php
php database/migrate_guest_management.php
php database/migrate_short_drama.php
php database/run_short_drama_worker.php &
exec php-fpm
+40
View File
@@ -0,0 +1,40 @@
server {
listen 80;
server_name _;
root /var/www/backend/public;
index index.html;
client_max_body_size 100m;
location ^~ /api {
include fastcgi_params;
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME /var/www/backend/public/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param QUERY_STRING s=$uri&$args;
fastcgi_buffering off;
fastcgi_read_timeout 600s;
}
location = /admin {
return 301 /admin/;
}
location ^~ /admin/ {
try_files $uri $uri/ /admin/index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
expires 7d;
access_log off;
try_files $uri =404;
}
location ~ \.php$ {
return 404;
}
}
+3
View File
@@ -0,0 +1,3 @@
[www]
clear_env = no
catch_workers_output = yes
+6
View File
@@ -0,0 +1,6 @@
expose_php = Off
memory_limit = 512M
upload_max_filesize = 100M
post_max_size = 110M
max_execution_time = 600
date.timezone = Asia/Shanghai
+166
View File
@@ -952,3 +952,169 @@ html[data-theme] .persona-avatar {
color: var(--accent-strong);
box-shadow: none;
}
/* Aurora UI — bright, calm and dimensional */
:root {
--bg-primary: #f3f6fb;
--bg-secondary: #ffffff;
--bg-tertiary: #eef2f8;
--bg-hover: #edf1ff;
--surface-raised: #ffffff;
--surface-inset: #f7f9fc;
--surface-neutral: rgba(67, 56, 202, 0.055);
--surface-neutral-hover: rgba(67, 56, 202, 0.1);
--glass-header: rgba(255, 255, 255, 0.86);
--glass-panel: rgba(255, 255, 255, 0.94);
--text-primary: #172033;
--text-secondary: #5f6b7e;
--text-muted: #929db0;
--accent: #5457d9;
--accent-hover: #4547c4;
--accent-strong: #3436a8;
--accent-soft: #eef0ff;
--border: #e4e9f2;
--border-strong: #d5ddea;
--danger: #dc4c64;
--success: #159570;
--warning: #c58116;
--button-primary-bg: #5154d8;
--button-primary-hover: #4144bf;
--button-primary-text: #ffffff;
--sidebar-width: 268px;
--shadow: 0 22px 60px rgba(43, 57, 91, 0.14);
--shadow-soft: 0 8px 28px rgba(43, 57, 91, 0.08);
--shadow-control: rgba(43, 57, 91, 0.1);
}
:root[data-theme="dark"] {
--bg-primary: #0e1422;
--bg-secondary: #151d2e;
--bg-tertiary: #1c2639;
--bg-hover: #242f46;
--surface-raised: #1a2437;
--surface-inset: #111929;
--surface-neutral: rgba(142, 147, 255, 0.08);
--surface-neutral-hover: rgba(142, 147, 255, 0.14);
--glass-header: rgba(21, 29, 46, 0.86);
--glass-panel: rgba(21, 29, 46, 0.94);
--text-primary: #f4f6fb;
--text-secondary: #aeb8ca;
--text-muted: #738097;
--accent: #979aff;
--accent-hover: #b1b3ff;
--accent-strong: #c8caff;
--accent-soft: rgba(111, 115, 255, 0.16);
--border: #263249;
--border-strong: #34415b;
--button-primary-bg: #777bf2;
--button-primary-hover: #8d90ff;
--button-primary-text: #ffffff;
--shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
--shadow-soft: 0 10px 30px rgba(0, 0, 0, 0.22);
}
html[data-theme] body,
html[data-theme] .admin-layout,
html[data-theme] .main-area,
html[data-theme] .main-content {
background:
radial-gradient(circle at 72% -12%, rgba(99, 102, 241, 0.1), transparent 34%),
radial-gradient(circle at 105% 90%, rgba(56, 189, 248, 0.07), transparent 30%),
var(--bg-primary) !important;
}
html[data-theme] .sidebar {
border-right: 1px solid rgba(255, 255, 255, 0.1);
background:
radial-gradient(circle at 15% -5%, rgba(125, 130, 255, 0.3), transparent 28%),
linear-gradient(180deg, #18213a 0%, #11182a 100%) !important;
box-shadow: 12px 0 34px rgba(27, 37, 66, 0.12) !important;
color: #eef2ff;
}
html[data-theme] .sidebar :is(.brand-copy strong, .admin-user-copy strong) { color: #f7f8ff; }
html[data-theme] .sidebar :is(.brand-copy small, .nav-group-title, .admin-user-copy small) { color: #8290ad; }
html[data-theme] .sidebar :is(.nav-item, .sidebar-member-link) { color: #aeb8cf; }
html[data-theme] .brand-icon {
border-radius: 12px;
background: linear-gradient(145deg, #8589ff, #5659db);
box-shadow: 0 8px 22px rgba(84, 87, 217, 0.38) !important;
}
html[data-theme] .sidebar-member-link {
border-color: rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.055);
}
html[data-theme] .nav-item { border-radius: 10px; }
html[data-theme] .nav-item:hover {
background: rgba(255, 255, 255, 0.07);
color: #ffffff;
transform: translateX(2px);
}
html[data-theme] .nav-item.router-link-active {
background: linear-gradient(90deg, rgba(129, 134, 255, 0.27), rgba(129, 134, 255, 0.1));
color: #ffffff;
box-shadow: inset 3px 0 0 #8e92ff;
}
html[data-theme] .router-link-active .nav-icon,
html[data-theme] .nav-icon {
border-radius: 9px;
background: rgba(255, 255, 255, 0.07);
}
html[data-theme] .sidebar-footer {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(6, 10, 20, 0.22);
}
html[data-theme] .sidebar .avatar {
border-color: rgba(142, 146, 255, 0.28);
border-radius: 10px;
background: rgba(129, 134, 255, 0.16);
color: #c8caff;
}
html[data-theme] .sidebar .btn-ghost {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.045);
color: #c5cde0;
}
html[data-theme] .main-content { padding: clamp(28px, 3vw, 48px); }
html[data-theme] .page-header { padding-left: 0; }
html[data-theme] .page-header::before { display: none; }
html[data-theme] .page-header h2 { color: var(--text-primary); font-size: clamp(26px, 2.4vw, 36px); }
html[data-theme] :is(.panel, .membership-card, .stat-card) {
border-color: rgba(213, 221, 234, 0.82);
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--shadow-soft) !important;
backdrop-filter: blur(16px);
}
:root[data-theme="dark"] :is(.panel, .membership-card, .stat-card) {
border-color: var(--border);
background: rgba(21, 29, 46, 0.9);
}
html[data-theme] .btn { border-radius: 10px; }
html[data-theme] .btn:hover { border-color: #cbd4e3; box-shadow: 0 5px 14px rgba(43, 57, 91, 0.08); transform: translateY(-1px); }
html[data-theme] .btn-primary {
border-color: transparent;
border-radius: 10px;
background: linear-gradient(135deg, #6265e5, #484bc8);
box-shadow: 0 8px 20px rgba(72, 75, 200, 0.24);
}
html[data-theme] .btn-primary:hover { border-color: transparent; background: linear-gradient(135deg, #7174ee, #4144bc); box-shadow: 0 10px 24px rgba(72, 75, 200, 0.3); }
html[data-theme] :is(.form-input, .form-select) { border-radius: 10px; background: var(--surface-inset); }
html[data-theme] :is(.form-input, .form-select):focus { border-color: #7a7de8; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.13); }
html[data-theme] .data-table th { background: rgba(247, 249, 252, 0.92); color: #7b879a; }
html[data-theme] .data-table tbody tr:hover td { background: rgba(238, 240, 255, 0.62); }
html[data-theme] .modal { border-radius: 16px; box-shadow: var(--shadow) !important; }
+4
View File
@@ -26,7 +26,9 @@ const props = defineProps({
const paths = {
dashboard: 'M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-12h6V4h-6v4Z',
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
visitors: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9v-1a7 7 0 0 1 14 0v1M18.5 8.5 21 11l-2.5 2.5M21 11h-5',
departments: 'M3 21h18M5 21V8l7-4 7 4v13M9 11h1m4 0h1m-6 4h1m4 0h1m-4 6v-3h2v3',
ticket: 'M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v3a2 2 0 0 0 0 4v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3a2 2 0 0 0 0-4V7Zm9 0v2m0 2v2m0 2v2',
roles: 'M12 3 4.5 6v5.5c0 4.7 3.2 8.1 7.5 9.5 4.3-1.4 7.5-4.8 7.5-9.5V6L12 3Zm-2.3 9.2 1.55 1.55 3.2-3.5',
conversations: 'M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v8ZM8 9h8M8 13h5',
memberships: 'm12 3 2.7 5.45 6.02.88-4.36 4.25 1.03 6L12 17.7 6.61 20.5l1.03-6L3.28 9.33l6.02-.88L12 3Z',
@@ -47,7 +49,9 @@ const paths = {
const aliases = {
'/dashboard': 'dashboard',
'/users': 'users',
'/guests': 'visitors',
'/departments': 'departments',
'/invitations': 'ticket',
'/roles': 'roles',
'/conversations': 'conversations',
'/memberships': 'memberships',
+22
View File
@@ -33,6 +33,17 @@ export const permissionTree = [
{ code: 'btn:user:delete', name: '删除用户', type: 'btn' }
]
},
{
code: 'menu:guests',
name: '访客管理',
type: 'menu',
path: '/guests',
icon: 'visitors',
children: [
{ code: 'btn:guest:status', name: '启用/禁用访客', type: 'btn' },
{ code: 'btn:guest:delete', name: '删除访客', type: 'btn' }
]
},
{
code: 'menu:departments',
name: '部门管理',
@@ -45,6 +56,17 @@ export const permissionTree = [
{ code: 'btn:dept:delete', name: '删除部门', type: 'btn' }
]
},
{
code: 'menu:invitations',
name: '邀请码管理',
type: 'menu',
path: '/invitations',
icon: 'ticket',
children: [
{ code: 'btn:invitation:create', name: '生成邀请码', type: 'btn' },
{ code: 'btn:invitation:revoke', name: '作废邀请码', type: 'btn' }
]
},
{
code: 'menu:roles',
name: '角色管理',
+3 -1
View File
@@ -103,7 +103,9 @@ const titleMap = computed(() =>
const currentTitle = computed(() => titleMap.value[route.path] || '管理后台')
const avatarLetter = computed(() => (auth.user?.username || 'A').charAt(0).toUpperCase())
const memberUrl = import.meta.env.VITE_MEMBER_URL || `${window.location.protocol}//${window.location.hostname}:5173`
const memberUrl = import.meta.env.VITE_MEMBER_URL || (import.meta.env.PROD
? `${window.location.origin}/`
: `${window.location.protocol}//${window.location.hostname}:5173`)
function handleLogout() {
auth.logout()
+2
View File
@@ -17,6 +17,7 @@ const routes = [
{ path: '', redirect: '/dashboard' },
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { menu: 'menu:dashboard' } },
{ path: 'users', name: 'Users', component: () => import('@/views/UsersView.vue'), meta: { menu: 'menu:users' } },
{ path: 'guests', name: 'Guests', component: () => import('@/views/GuestsView.vue'), meta: { menu: 'menu:guests' } },
{
path: 'conversations',
name: 'Conversations',
@@ -30,6 +31,7 @@ const routes = [
{ path: 'memberships', name: 'Memberships', component: () => import('@/views/MembershipsView.vue'), meta: { menu: 'menu:memberships' } },
{ path: 'roles', name: 'Roles', component: () => import('@/views/RolesView.vue'), meta: { menu: 'menu:roles' } },
{ path: 'departments', name: 'Departments', component: () => import('@/views/DepartmentsView.vue'), meta: { menu: 'menu:departments' } },
{ path: 'invitations', name: 'Invitations', component: () => import('@/views/InvitationCodesView.vue'), meta: { menu: 'menu:invitations' } },
{ path: 'permissions', name: 'Permissions', component: () => import('@/views/PermissionsView.vue'), meta: { menu: 'menu:permissions' } },
{ path: 'settings', name: 'Settings', component: () => import('@/views/SettingsView.vue'), meta: { menu: 'menu:settings' } }
]
+313
View File
@@ -0,0 +1,313 @@
<template>
<div>
<div class="page-header">
<h2>访客管理</h2>
<p>单独记录匿名访问者的使用情况不计入注册用户管理列表</p>
</div>
<div class="guest-summary">
<div class="summary-card">
<span>当前记录</span>
<strong>{{ total }}</strong>
<small>匿名访客</small>
</div>
<div class="summary-card">
<span>本页会话</span>
<strong>{{ pageConversationCount }}</strong>
<small>未删除会话</small>
</div>
<div class="summary-card">
<span>本页消息</span>
<strong>{{ pageMessageCount }}</strong>
<small>累计消息</small>
</div>
</div>
<div class="toolbar guest-toolbar">
<div class="filter-group">
<button
v-for="item in statusOptions"
:key="item.value"
class="btn btn-ghost filter-btn"
:class="{ active: status === item.value }"
type="button"
@click="setStatus(item.value)"
>{{ item.label }}</button>
</div>
<form class="search-form" @submit.prevent="searchGuests">
<input v-model.trim="keyword" class="form-input" placeholder="搜索访客编号" />
<button class="btn btn-ghost" type="submit">搜索</button>
</form>
</div>
<div class="panel">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>访客编号</th>
<th>状态</th>
<th>会话数</th>
<th>消息数</th>
<th>首次访问</th>
<th>最近访问</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="guest in guests" :key="guest.id">
<td>{{ guest.id }}</td>
<td>
<div class="guest-identity">
<strong>访客 #{{ shortGuestId(guest.username) }}</strong>
<code>{{ guest.username }}</code>
</div>
</td>
<td>
<span class="badge" :class="guest.status === 'active' ? 'badge-success' : 'badge-danger'">
{{ guest.status === 'active' ? '正常' : '已禁用' }}
</span>
</td>
<td>{{ guest.conversation_count || 0 }}</td>
<td>{{ guest.message_count || 0 }}</td>
<td>{{ formatDate(guest.created_at) }}</td>
<td>{{ formatDate(guest.last_login_at) }}</td>
<td class="actions-cell">
<button
v-if="auth.hasButton('btn:guest:status')"
class="btn btn-ghost"
type="button"
@click="toggleStatus(guest)"
>{{ guest.status === 'active' ? '禁用' : '启用' }}</button>
<button
v-if="auth.hasButton('btn:guest:delete')"
class="btn btn-ghost danger"
type="button"
@click="removeGuest(guest)"
>删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="loading" class="empty">正在加载...</p>
<p v-else-if="!guests.length" class="empty">暂无访客记录</p>
<div v-if="total > limit" class="pagination">
<button class="btn btn-ghost" :disabled="page <= 1" @click="changePage(page - 1)">上一页</button>
<span>{{ page }} / {{ totalPages }}</span>
<button class="btn btn-ghost" :disabled="page >= totalPages" @click="changePage(page + 1)">下一页</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const guests = ref([])
const loading = ref(false)
const page = ref(1)
const limit = 20
const total = ref(0)
const status = ref('')
const keyword = ref('')
const statusOptions = [
{ label: '全部', value: '' },
{ label: '正常', value: 'active' },
{ label: '已禁用', value: 'disabled' }
]
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
const pageConversationCount = computed(() => guests.value.reduce((sum, item) => sum + Number(item.conversation_count || 0), 0))
const pageMessageCount = computed(() => guests.value.reduce((sum, item) => sum + Number(item.message_count || 0), 0))
onMounted(loadGuests)
async function loadGuests() {
loading.value = true
try {
const res = await api.get('/admin/guests', {
params: {
page: page.value,
limit,
status: status.value || undefined,
keyword: keyword.value || undefined
}
})
const data = res.data.data || {}
guests.value = data.list || []
total.value = data.total ?? guests.value.length
} finally {
loading.value = false
}
}
function setStatus(value) {
status.value = value
page.value = 1
loadGuests()
}
function searchGuests() {
page.value = 1
loadGuests()
}
async function changePage(nextPage) {
page.value = nextPage
await loadGuests()
}
async function toggleStatus(guest) {
const nextStatus = guest.status === 'active' ? 'disabled' : 'active'
const action = nextStatus === 'active' ? '启用' : '禁用'
if (!confirm(`确定${action}访客 #${shortGuestId(guest.username)}`)) return
try {
await api.put(`/admin/guests/${guest.id}/status`, { status: nextStatus })
await loadGuests()
} catch (e) {
alert(e.message || `${action}失败`)
}
}
async function removeGuest(guest) {
if (!confirm(`确定删除访客 #${shortGuestId(guest.username)}?其会话和消息记录也会一并删除,此操作不可恢复。`)) return
try {
await api.delete(`/admin/guests/${guest.id}`)
if (guests.value.length === 1 && page.value > 1) page.value -= 1
await loadGuests()
} catch (e) {
alert(e.message || '删除失败')
}
}
function shortGuestId(username) {
return String(username || '').replace(/^guest_/, '').slice(-8).toUpperCase()
}
function formatDate(value) {
if (!value) return '-'
return new Date(String(value).replace(' ', 'T')).toLocaleString('zh-CN', { hour12: false })
}
</script>
<style scoped>
.guest-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-card {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 16px;
padding: 17px 18px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--glass-panel);
}
.summary-card span,
.summary-card small {
color: var(--text-muted);
font-size: 11px;
}
.summary-card strong {
grid-row: span 2;
font-size: 27px;
letter-spacing: -0.04em;
}
.guest-toolbar,
.filter-group,
.search-form,
.actions-cell {
display: flex;
align-items: center;
gap: 7px;
}
.guest-toolbar {
justify-content: space-between;
margin-bottom: 16px;
}
.filter-btn.active {
border-color: var(--accent-line);
background: var(--accent-soft);
color: var(--accent);
}
.search-form .form-input {
width: min(260px, 42vw);
}
.table-scroll {
overflow-x: auto;
}
.guest-identity {
display: flex;
min-width: 220px;
flex-direction: column;
gap: 5px;
}
.guest-identity strong {
font-size: 13px;
}
.guest-identity code {
width: fit-content;
padding: 3px 6px;
color: var(--text-muted);
font-size: 10px;
}
.empty {
padding: 34px;
color: var(--text-muted);
text-align: center;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 18px;
color: var(--text-secondary);
font-size: 13px;
}
.danger {
color: var(--danger);
}
@media (max-width: 760px) {
.guest-summary {
grid-template-columns: 1fr;
}
.guest-toolbar {
align-items: stretch;
flex-direction: column;
}
.search-form .form-input {
width: 100%;
}
}
</style>
@@ -0,0 +1,285 @@
<template>
<div>
<div class="page-header">
<h2>邀请码管理</h2>
<p>生成一次性注册码并绑定部门新用户注册成功后会自动归属到该部门</p>
</div>
<div class="toolbar invitation-toolbar">
<div class="filter-group">
<button
v-for="item in filters"
:key="item.value"
type="button"
class="btn btn-ghost filter-btn"
:class="{ active: statusFilter === item.value }"
@click="setFilter(item.value)"
>{{ item.label }}</button>
</div>
<button v-if="auth.hasButton('btn:invitation:create')" class="btn btn-primary" @click="openCreate">
生成邀请码
</button>
</div>
<div class="panel invitation-panel">
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>邀请码</th>
<th>归属部门</th>
<th>状态</th>
<th>有效期</th>
<th>使用用户</th>
<th>创建人 / 时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in invitations" :key="item.id">
<td><code class="invite-code">{{ item.code }}</code></td>
<td>{{ item.department_name || '未指定部门' }}</td>
<td><span class="badge" :class="statusMeta(item).className">{{ statusMeta(item).label }}</span></td>
<td>{{ item.expires_at ? formatDate(item.expires_at) : '长期有效' }}</td>
<td>{{ item.used_by_name || '-' }}</td>
<td>
<div class="creator-cell">
<span>{{ item.creator_name || '-' }}</span>
<small>{{ formatDate(item.created_at) }}</small>
</div>
</td>
<td>
<button class="btn btn-ghost" type="button" @click="copyCode(item.code)">复制</button>
<button
v-if="item.status === 'active' && auth.hasButton('btn:invitation:revoke')"
class="btn btn-ghost danger"
type="button"
@click="revoke(item)"
>作废</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="loading" class="empty">正在加载...</p>
<p v-else-if="!invitations.length" class="empty">暂无邀请码</p>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>生成邀请码</h3>
<button type="button" @click="closeModal">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>注册后归属部门</label>
<select v-model="form.department_id" class="form-select">
<option value="">不指定部门</option>
<option v-for="dept in departments" :key="dept.id" :value="dept.id">
{{ dept.label || dept.name }}
</option>
</select>
<p class="form-help">指定后使用该邀请码注册的用户会自动加入此部门</p>
</div>
<div class="form-group">
<label>过期时间</label>
<input v-model="form.expires_at" type="datetime-local" class="form-input" :min="minExpiresAt" />
<p class="form-help">留空表示长期有效邀请码无论是否设置有效期都只能使用一次</p>
</div>
<p v-if="error" class="form-error">{{ error }}</p>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" type="button" @click="closeModal">取消</button>
<button class="btn btn-primary" type="button" :disabled="creating" @click="createInvitation">
{{ creating ? '生成中...' : '确认生成' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import api from '@/api'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const invitations = ref([])
const departments = ref([])
const loading = ref(false)
const creating = ref(false)
const showModal = ref(false)
const statusFilter = ref('')
const error = ref('')
const form = reactive({ department_id: '', expires_at: '' })
const filters = [
{ label: '全部', value: '' },
{ label: '可使用', value: 'active' },
{ label: '已使用', value: 'used' },
{ label: '已作废', value: 'revoked' }
]
const minExpiresAt = computed(() => {
const date = new Date(Date.now() + 60 * 60 * 1000)
date.setMinutes(date.getMinutes() - date.getTimezoneOffset())
return date.toISOString().slice(0, 16)
})
onMounted(async () => {
await Promise.all([loadInvitations(), loadDepartments()])
})
async function loadInvitations() {
loading.value = true
try {
const res = await api.get('/admin/invitations', {
params: statusFilter.value ? { status: statusFilter.value } : {}
})
invitations.value = res.data.data || []
} finally {
loading.value = false
}
}
async function loadDepartments() {
const res = await api.get('/admin/department-options')
departments.value = res.data.data || []
}
function setFilter(value) {
statusFilter.value = value
loadInvitations()
}
function openCreate() {
form.department_id = ''
form.expires_at = ''
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
}
async function createInvitation() {
creating.value = true
error.value = ''
try {
const res = await api.post('/admin/invitations', {
department_id: form.department_id || null,
expires_at: form.expires_at || null
})
await copyCode(res.data.data.code)
closeModal()
statusFilter.value = ''
await loadInvitations()
} catch (e) {
error.value = e.message || '生成失败'
} finally {
creating.value = false
}
}
async function revoke(item) {
if (!confirm(`确定作废邀请码「${item.code}」?`)) return
try {
await api.put(`/admin/invitations/${item.id}/revoke`)
await loadInvitations()
} catch (e) {
alert(e.message || '作废失败')
}
}
async function copyCode(code) {
try {
await navigator.clipboard.writeText(code)
} catch {
window.prompt('请复制邀请码', code)
}
}
function statusMeta(item) {
if (item.status === 'used') return { label: '已使用', className: 'badge-info' }
if (item.status === 'revoked') return { label: '已作废', className: 'badge-danger' }
if (item.expires_at && new Date(item.expires_at.replace(' ', 'T')).getTime() <= Date.now()) {
return { label: '已过期', className: 'badge-warn' }
}
return { label: '可使用', className: 'badge-success' }
}
function formatDate(value) {
if (!value) return '-'
return new Date(String(value).replace(' ', 'T')).toLocaleString('zh-CN', { hour12: false })
}
</script>
<style scoped>
.invitation-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.filter-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-btn.active {
border-color: var(--accent-line);
background: var(--accent-soft);
color: var(--accent);
}
.invitation-panel,
.table-scroll {
overflow-x: auto;
}
.invite-code {
display: inline-block;
padding: 6px 8px;
white-space: nowrap;
letter-spacing: 0.04em;
}
.creator-cell {
display: flex;
flex-direction: column;
gap: 3px;
}
.creator-cell small,
.form-help {
color: var(--text-muted);
font-size: 11px;
}
.form-help {
margin: 7px 0 0;
line-height: 1.5;
}
.empty {
padding: 34px;
color: var(--text-muted);
text-align: center;
}
.danger {
color: var(--danger);
}
@media (max-width: 680px) {
.invitation-toolbar {
align-items: stretch;
flex-direction: column;
}
}
</style>
+3 -3
View File
@@ -80,14 +80,14 @@
<select v-model="form.provider" class="form-input" @change="onProviderChange">
<option value="openai">OpenAI 兼容GPT / DeepSeek / vLLM / SGLang </option>
<option value="dify">Dify 应用chat-messages 接口</option>
<option value="comfy">ComfyUI 文生图</option>
<option value="comfy">ComfyUI图片 / 短剧工作节点</option>
</select>
<p class="field-hint" v-if="form.provider === 'dify'">
Dify 使用自己的一套接口协议/chat-messages OpenAI /chat/completions 不同不要混用否则会报 404
</p>
<p class="field-hint" v-if="form.provider === 'comfy'">
用户发送的文字会写入工作流的提示词节点并调用本地 ComfyUI 生图可在下方粘贴自定义工作流 JSON留空则使用服务器默认文件
<code>backend/config/comfyui_workflow.json</code>当前为 ZImageTurbo
每个不同的 API 地址会被短剧系统识别为一个独立工作节点最多并发 3 相同地址仍按 ComfyUI 单队列串行避免假并发和显存溢出
下方工作流 JSON 用于对话生图留空则使用服务器默认文件 <code>backend/config/comfyui_workflow.json</code>
</p>
</div>
<div class="form-group" v-if="form.provider === 'openai'">
+4 -2
View File
@@ -220,7 +220,8 @@ const features = reactive({
upload_image: true,
upload_video: true,
upload_file: true,
paste_image: true
paste_image: true,
short_drama: true
})
const voicePersona = reactive({
@@ -282,7 +283,8 @@ const featureLabels = {
upload_image: '上传图片',
upload_video: '上传视频',
upload_file: '上传文件',
paste_image: '粘贴图片'
paste_image: '粘贴图片',
short_drama: '短剧工坊入口(登录用户)'
}
const personaInitial = computed(() => (voicePersona.name || 'AI').trim().slice(0, 1).toUpperCase())
+149
View File
@@ -1108,3 +1108,152 @@ html[data-theme] .restore-progress {
border-radius: 8px;
background: var(--bg-secondary);
}
/* Aurora UI — light-first blue/violet product theme */
:root {
--bg-primary: #f3f6fb;
--bg-secondary: #ffffff;
--bg-tertiary: #eef2f8;
--bg-hover: #edf1ff;
--bg-soft: #f7f9fc;
--surface-raised: #ffffff;
--surface-inset: #f7f9fc;
--surface-neutral: rgba(67, 56, 202, 0.055);
--surface-neutral-hover: rgba(67, 56, 202, 0.1);
--glass-header: rgba(255, 255, 255, 0.86);
--glass-panel: rgba(255, 255, 255, 0.94);
--text-primary: #172033;
--text-secondary: #5f6b7e;
--text-muted: #929db0;
--accent: #5457d9;
--accent-hover: #4547c4;
--accent-strong: #3436a8;
--accent-soft: #eef0ff;
--accent-line: #c7caff;
--border: #e4e9f2;
--border-strong: #d5ddea;
--danger: #dc4c64;
--danger-soft: #fff0f3;
--success: #159570;
--button-primary-bg: #5154d8;
--button-primary-hover: #4144bf;
--button-primary-text: #ffffff;
--shadow: 0 22px 60px rgba(43, 57, 91, 0.14);
--shadow-soft: 0 8px 28px rgba(43, 57, 91, 0.08);
--shadow-composer: 0 18px 46px rgba(43, 57, 91, 0.14);
--shadow-control: rgba(43, 57, 91, 0.1);
}
:root[data-theme="dark"] {
--bg-primary: #0e1422;
--bg-secondary: #151d2e;
--bg-tertiary: #1c2639;
--bg-hover: #242f46;
--bg-soft: #111929;
--surface-raised: #1a2437;
--surface-inset: #111929;
--surface-neutral: rgba(142, 147, 255, 0.08);
--surface-neutral-hover: rgba(142, 147, 255, 0.14);
--glass-header: rgba(21, 29, 46, 0.86);
--glass-panel: rgba(21, 29, 46, 0.94);
--text-primary: #f4f6fb;
--text-secondary: #aeb8ca;
--text-muted: #738097;
--accent: #979aff;
--accent-hover: #b1b3ff;
--accent-strong: #c8caff;
--accent-soft: rgba(111, 115, 255, 0.16);
--accent-line: #565b9d;
--border: #263249;
--border-strong: #34415b;
--danger-soft: rgba(220, 76, 100, 0.14);
--button-primary-bg: #777bf2;
--button-primary-hover: #8d90ff;
--button-primary-text: #ffffff;
--shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
--shadow-soft: 0 10px 30px rgba(0, 0, 0, 0.22);
--shadow-composer: 0 20px 54px rgba(0, 0, 0, 0.3);
}
html[data-theme] .chat-layout,
html[data-theme] .chat-main {
background:
radial-gradient(circle at 78% -12%, rgba(99, 102, 241, 0.11), transparent 34%),
radial-gradient(circle at 110% 82%, rgba(56, 189, 248, 0.07), transparent 30%),
var(--bg-primary) !important;
}
html[data-theme] .sidebar {
border-right: 1px solid rgba(255, 255, 255, 0.1);
background:
radial-gradient(circle at 15% -5%, rgba(125, 130, 255, 0.3), transparent 28%),
linear-gradient(180deg, #18213a 0%, #11182a 100%) !important;
box-shadow: 12px 0 34px rgba(27, 37, 66, 0.12) !important;
color: #eef2ff;
}
html[data-theme] .sidebar :is(.sidebar-brand strong, .user-info strong) { color: #f7f8ff; }
html[data-theme] .sidebar :is(.sidebar-brand p, .conversation-heading, .user-info span) { color: #8290ad; }
html[data-theme] .sidebar :is(.conversation-item, .user-row) { color: #aeb8cf; }
html[data-theme] .brand-badge {
border-radius: 12px;
background: linear-gradient(145deg, #8589ff, #5659db);
box-shadow: 0 8px 22px rgba(84, 87, 217, 0.38) !important;
}
html[data-theme] .new-chat-btn {
border: 0;
border-radius: 11px;
background: linear-gradient(135deg, #7478ed, #5054d1);
color: #ffffff;
box-shadow: 0 9px 22px rgba(70, 74, 190, 0.32);
}
html[data-theme] .new-chat-btn:hover { background: linear-gradient(135deg, #8588f5, #5b5fda); box-shadow: 0 11px 26px rgba(70, 74, 190, 0.4); transform: translateY(-1px); }
html[data-theme] .conversation-item { border-radius: 10px; }
html[data-theme] .conversation-item:hover { background: rgba(255, 255, 255, 0.07); color: #ffffff; transform: translateX(2px); }
html[data-theme] .conversation-item.active {
background: linear-gradient(90deg, rgba(129, 134, 255, 0.27), rgba(129, 134, 255, 0.1));
color: #ffffff;
box-shadow: inset 3px 0 0 #8e92ff;
}
html[data-theme] .conversation-icon-wrap { border-radius: 9px; background: rgba(255, 255, 255, 0.07); }
html[data-theme] .sidebar-footer { border-color: rgba(255, 255, 255, 0.08); background: rgba(6, 10, 20, 0.22); }
html[data-theme] .chat-header {
border-color: rgba(213, 221, 234, 0.8);
background: var(--glass-header);
box-shadow: 0 8px 28px rgba(43, 57, 91, 0.07);
backdrop-filter: blur(18px);
}
html[data-theme] .welcome-icon {
border-radius: 16px;
background: linear-gradient(145deg, #f0f1ff, #e2e5ff);
color: #5154d8;
box-shadow: 0 10px 26px rgba(81, 84, 216, 0.16);
}
html[data-theme] .welcome h2 { font-size: clamp(28px, 4vw, 44px); color: var(--text-primary); }
html[data-theme] :is(.input-wrapper, .composer, .workflow-panel) {
border-color: rgba(202, 211, 226, 0.9);
border-radius: 18px;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-composer) !important;
backdrop-filter: blur(16px);
}
:root[data-theme="dark"] :is(.input-wrapper, .composer, .workflow-panel) { border-color: var(--border-strong); background: rgba(21, 29, 46, 0.94); }
html[data-theme] .input-wrapper:focus-within { border-color: #8588ee; box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12), var(--shadow-composer) !important; }
html[data-theme] .btn { border-radius: 10px; }
html[data-theme] .btn-primary,
html[data-theme] :is(.composer-footer button, .workflow-command > button, .primary-workflow-button) {
border-color: transparent;
border-radius: 11px;
background: linear-gradient(135deg, #6265e5, #484bc8);
color: #ffffff;
box-shadow: 0 8px 20px rgba(72, 75, 200, 0.24);
}
html[data-theme] .message.assistant .message-avatar { border-radius: 12px; background: linear-gradient(145deg, #eeefff, #dfe2ff); color: #4d50cc; }
html[data-theme] .message.user .message-content { border-radius: 18px 18px 5px 18px; background: linear-gradient(135deg, #5d61dc, #4548bb); color: #ffffff; box-shadow: 0 8px 22px rgba(69, 72, 187, 0.18); }
+11 -8
View File
@@ -155,7 +155,7 @@
/>
<button
v-if="settings.agents.length"
v-if="!auth.isGuest && settings.agents.length"
class="agent-trigger"
:class="{ active: activeAgent }"
type="button"
@@ -184,7 +184,7 @@
</button>
<button
v-if="imageModelOptions.length"
v-if="!auth.isGuest && imageModelOptions.length"
class="mode-tab"
:class="{ active: isComfyModel }"
type="button"
@@ -231,7 +231,7 @@
</button>
<button
v-if="!hasDraft"
v-if="!auth.isGuest && !hasDraft"
ref="voiceTriggerRef"
class="voice-trigger"
type="button"
@@ -267,8 +267,8 @@
<p v-else-if="activeAgent" class="input-hint">
Agent 会自动识别任务并调用合适的语言或图片生成模型
</p>
<p v-else-if="auth.isGuest && features.upload_image" class="input-hint input-hint-warn">
游客模式不支持发送图片登录后即可上传
<p v-else-if="auth.isGuest" class="input-hint input-hint-warn">
游客仅支持 qwen3.6 文本对话登录后可使用全部模型Agent文件和语音功能
</p>
<p v-else-if="imageBlockedByModel" class="input-hint input-hint-warn">当前模型不支持图片识别已自动隐藏图片上传</p>
<p v-else class="input-hint">{{ defaultInputHint }}</p>
@@ -373,18 +373,20 @@ const activeModel = computed(() => {
})
const activeAgent = computed(() => {
if (auth.isGuest) return null
const selected = settings.agents.find(item => item.id === chat.selectedAgentId)
if (selected) return selected
return chat.selectedAgentId ? settings.agents.find(item => item.id === 'auto') || null : null
})
const isComfyModel = computed(() => activeModel.value?.provider === 'comfy' && !activeAgent.value)
const imageModelOptions = computed(() => settings.models.filter(m => m.provider === 'comfy'))
const imageModelOptions = computed(() => auth.isGuest ? [] : settings.models.filter(m => m.provider === 'comfy'))
const textModelOptions = computed(() => settings.models.filter(m => m.provider !== 'comfy'))
const selectedModelValue = computed(() => chat.selectedModelId == null ? '' : String(chat.selectedModelId))
const voicePersonaName = computed(() => settings.voicePersona?.name || 'AI 客服')
const voicePersonaGreeting = computed(() => settings.voicePersona?.greeting || '您好,请问有什么可以帮您?')
const inputPlaceholder = computed(() => {
if (auth.isGuest) return '使用 qwen3.6 开始文字对话,登录后可解锁全部模型和工具'
if (isComfyModel.value) return '描述要生成或处理的图片;可上传原图后说“去水印、换背景、修复照片”'
if (activeAgent.value?.placeholder) return activeAgent.value.placeholder
return '给我一段需求,我可以继续对话,也可以帮你联动图片生成'
@@ -401,8 +403,8 @@ const modelSupportsImage = computed(() => {
})
const canUploadImage = computed(() => !auth.isGuest && features.value.upload_image && perms.value.can_upload_image && modelSupportsImage.value)
const canUploadVideo = computed(() => !isComfyModel.value && features.value.upload_video && perms.value.can_upload_video)
const canUploadFile = computed(() => !isComfyModel.value && features.value.upload_file && perms.value.can_upload_file)
const canUploadVideo = computed(() => !auth.isGuest && !isComfyModel.value && features.value.upload_video && perms.value.can_upload_video)
const canUploadFile = computed(() => !auth.isGuest && !isComfyModel.value && features.value.upload_file && perms.value.can_upload_file)
const dropOverlayTitle = computed(() => {
if (canUploadImage.value) return '松开即可添加图片'
@@ -530,6 +532,7 @@ function voiceFeatureAvailable() {
}
async function startVoiceMode() {
if (auth.isGuest) return
if (chat.sending) {
notification.show({
type: 'info',

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